C# 8.0 was released in September 2019 alongside .NET Core 3.0. It introduced several features to improve null safety, asynchronous programming, code readability, and resource management.
We will cover these six important features with explanations, example programs, execution flows, and interview key points.
1. Nullable Reference Types
What are Nullable Reference Types?
Before C# 8.0, reference types such as string, Employee, and Customer could contain null without compiler warnings.
C# 8.0 introduced Nullable Reference Types (NRT) to help developers identify potential NullReferenceException problems during compilation.
With nullable reference types enabled:
-
stringmeans the variable is intended to contain a non-null value. -
string?means the variable can contain a string ornull.
Example Program
#nullable enable
public class Program
{
public static void Main()
{
string name = "John";
string? city = null;
Console.WriteLine(name);
if (city != null)
{
Console.WriteLine(city.Length);
}
else
{
Console.WriteLine("City is null");
}
}
}
Output:
John
City is null
Program Explanation
-
#nullable enableenables nullable reference type analysis. -
string namedeclares a non-nullable reference variable. -
string? cityexplicitly allows null. -
The
ifcondition checks whethercityis null before accessingLength. -
This helps prevent runtime
NullReferenceExceptionerrors.
How It Works
Nullable Reference Types
|
v
string name
|
v
Non-nullable reference
|
v
Compiler warns about
possible null assignments
string? city
|
v
Nullable reference
|
v
Can contain null
|
v
Check before accessing
members
Important Difference
|
Before C# 8 |
C# 8 with NRT enabled |
|---|---|
|
|
Compiler produces a warning |
|
No nullable reference annotations |
Supports |
|
Limited compiler null analysis |
Flow-based null analysis |
|
Null errors often found at runtime |
Many potential errors identified during compilation |
Real-Time Example – ASP.NET Core Web API
public class CustomerDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Email { get; set; }
}
Here:
-
Nameis intended to be non-null. -
Emailis optional and can be null. -
The compiler can warn when nullable values are used unsafely.
Key Points
-
Introduced in C# 8.0.
-
Enable using
<Nullable>enable</Nullable>in the.csprojfile. -
Nullable reference types are primarily a compile-time feature, not runtime null protection.
-
string?andstringare both reference types at runtime. -
Nullability warnings do not automatically enforce API request validation.
-
Particularly useful for DTOs, services, repositories, and entity models.
Interview Answer: Nullable Reference Types improve code safety by distinguishing nullable and non-nullable references and providing compile-time warnings about potential null-related errors.
2. Async Streams (IAsyncEnumerable<T>)
What are Async Streams?
C# 8.0 introduced IAsyncEnumerable<T> to allow applications to produce and consume data asynchronously, one item at a time.
Before C# 8.0, asynchronous methods commonly returned Task<List<T>>, meaning the caller typically waited for the entire collection.
With IAsyncEnumerable<T>, items can be processed as they become available.
Example Program
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
public class Program
{
public static async IAsyncEnumerable<int> GetNumbersAsync()
{
for (int i = 1; i <= 3; i++)
{
await Task.Delay(1000);
yield return i;
}
}
public static async Task Main()
{
await foreach (int number in GetNumbersAsync())
{
Console.WriteLine(number);
}
}
}
Output: One number appears approximately every second.
1
2
3
Program Explanation
-
IAsyncEnumerable<int>represents an asynchronous sequence of integers. -
Task.Delay(1000)simulates an asynchronous operation. -
yield return iproduces one number at a time. -
await foreachasynchronously requests and processes each number. -
The caller does not need to wait for all three numbers before processing the first.
How It Works
GetNumbersAsync()
|
v
Task.Delay(1000)
|
v
yield return 1 ------> Consumer prints 1
|
v
Task.Delay(1000)
|
v
yield return 2 ------> Consumer prints 2
|
v
Task.Delay(1000)
|
v
yield return 3 ------> Consumer prints 3
|
v
Complete
Task<List<T>> vs IAsyncEnumerable<T>
|
Feature |
|
|
|---|---|---|
|
Result |
Entire collection |
Items sequentially |
|
Processing |
Usually after completion |
As items arrive |
|
Memory |
Often holds all results |
Can reduce buffering |
|
Consumption |
|
|
|
Best for |
Complete result sets |
Asynchronous streaming |
Real-Time Example
Consider an ASP.NET Core Web API retrieving thousands of records from a database.
With a fully buffered approach:
Database
|
v
Fetch 10,000 records
|
v
Build complete List
|
v
Return results
With asynchronous streaming:
Database
|
v
Fetch record
|
v
Process record
|
v
Fetch next record
|
v
Process next record
Streaming can reduce memory usage and improve time to first item when the underlying data source supports streaming.
Key Points
-
Introduced in C# 8.0.
-
Uses
IAsyncEnumerable<T>,yield return, andawait foreach. -
Supports asynchronous iteration without loading all results first.
-
Useful for large datasets, streaming APIs, and asynchronous data sources.
-
Supports cancellation through mechanisms such as
WithCancellation(). -
Actual HTTP response streaming also depends on serialization, middleware, and buffering behavior.
Interview Answer: IAsyncEnumerable<T> enables asynchronous streaming of data, allowing consumers to process items as they become available instead of waiting for the entire collection.
3. Switch Expressions
What are Switch Expressions?
C# 8.0 introduced Switch Expressions as a concise alternative to traditional switch statements.
A switch expression evaluates an input and returns a value based on matching patterns.
Traditional Switch Statement
string GetStatus(int status)
{
switch (status)
{
case 1:
return "Pending";
case 2:
return "Approved";
case 3:
return "Rejected";
default:
return "Unknown";
}
}
C# 8.0 Switch Expression
using System;
public class Program
{
public static void Main()
{
int status = 2;
string result = status switch
{
1 => "Pending",
2 => "Approved",
3 => "Rejected",
_ => "Unknown"
};
Console.WriteLine(result);
}
}
Output:
Approved
Program Explanation
-
status switchevaluates thestatusvariable. -
1 => "Pending"returns Pending when status equals 1. -
2 => "Approved"returns Approved when status equals 2. -
_is a discard pattern that matches any remaining value. -
The matched expression's result is assigned to
result.
How It Works
status = 2
|
v
Switch Expression
|
+-----+------+
| | |
1 2 3
| | |
Pending | Rejected
|
v
Approved
Switch Statement vs Switch Expression
|
Switch Statement |
Switch Expression |
|---|---|
|
Uses |
Uses |
|
Uses |
Uses |
|
Often uses |
Produces a value |
|
Suitable for multi-step logic |
Suitable for concise value selection |
|
Can contain statement blocks |
Arms contain expressions |
Real-Time Example – Order Status
public static string GetOrderStatus(int status)
{
return status switch
{
1 => "Order Created",
2 => "Payment Completed",
3 => "Order Shipped",
4 => "Order Delivered",
_ => "Invalid Status"
};
}
Usage:
Console.WriteLine(GetOrderStatus(3));
Output:
Order Shipped
Key Points
-
Introduced in C# 8.0.
-
Makes simple conditional mappings more readable.
-
Uses
=>instead ofcase. -
_represents a fallback pattern. -
Supports pattern matching and
whenguards. -
If no pattern matches, a runtime exception can occur, so handle all expected inputs.
Interview Answer: Switch Expressions provide a concise way to evaluate conditions and return values using pattern matching, reducing repetitive code compared with traditional switch statements.
4. Using Declarations
What are Using Declarations?
C# 8.0 introduced Using Declarations to simplify resource management.
They automatically call Dispose() when a variable goes out of its enclosing scope.
This is particularly useful when working with:
-
File streams
-
Database connections
-
Stream readers and writers
-
Other
IDisposableresources
Before C# 8.0 – Using Statement
using (StreamWriter writer = new StreamWriter("test.txt"))
{
writer.WriteLine("Hello World");
}
The resource is disposed when execution leaves the using block.
C# 8.0 – Using Declaration
using System;
using System.IO;
public class Program
{
public static void Main()
{
using StreamWriter writer = new StreamWriter("test.txt");
writer.WriteLine("Hello World");
Console.WriteLine("File written successfully");
}
}
Output:
File written successfully
Program Explanation
-
using StreamWriter writercreates a resource managed by a using declaration. -
WriteLine()writes text into the file. -
The resource remains available until the enclosing scope ends.
-
When
Main()exits,Dispose()is automatically called. -
Disposal also occurs when the scope exits because of an exception.
How It Works
Main() starts
|
v
Create StreamWriter
|
v
Write data into file
|
v
Execute remaining statements
|
v
Main() scope ends
|
v
Automatically call Dispose()
|
v
File resource released
Using Statement vs Using Declaration
|
Using Statement |
Using Declaration |
|---|---|
|
|
|
|
Requires a block |
No separate block |
|
Disposes at block exit |
Disposes at enclosing scope exit |
|
Explicit lifetime boundary |
Less indentation |
Real-Time Example – SQL Server
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
// Execute database operations
When the enclosing scope ends, the connection is disposed, returning the underlying connection to the pool when pooling is enabled.
Key Points
-
Introduced in C# 8.0.
-
Reduces unnecessary nesting.
-
Automatically disposes resources at scope exit.
-
Works with types implementing
IDisposable. -
For asynchronous disposal, use
await usingwithIAsyncDisposable. -
A using declaration does not necessarily dispose immediately after the final use; disposal occurs at scope exit.
Interview Answer: Using Declarations simplify resource management by automatically disposing IDisposable objects at the end of the enclosing scope without requiring a separate using block.
5. Default Interface Methods
What are Default Interface Methods?
Before C# 8.0, interfaces primarily defined contracts, and implementing classes were required to provide implementations for their abstract members.
C# 8.0 introduced Default Interface Methods, allowing interfaces to contain method implementations.
This is particularly useful when extending existing interfaces without forcing every implementing class to implement a new method.
Example Program
using System;
public interface INotification
{
void Send(string message);
void Log()
{
Console.WriteLine("Notification logged");
}
}
public class EmailNotification : INotification
{
public void Send(string message)
{
Console.WriteLine($"Email sent: {message}");
}
}
public class Program
{
public static void Main()
{
INotification notification = new EmailNotification();
notification.Send("Welcome");
notification.Log();
}
}
Output:
Email sent: Welcome
Notification logged
Program Explanation
-
INotificationdefines an abstract methodSend(). -
Log()has a default implementation inside the interface. -
EmailNotificationmust implementSend(). -
EmailNotificationdoes not need to implementLog(). -
When called through
INotification, the defaultLog()implementation executes.
How It Works
INotification
+---------------------+
| Send() |
| No implementation |
| |
| Log() |
| Default behavior |
+---------------------+
|
v
EmailNotification
+---------------------+
| Send() implemented |
| |
| Log() inherited as |
| interface behavior |
+---------------------+
|
v
INotification reference
|
+------+------+
| |
Send() Log()
| |
v v
Class method Interface default
Important: How to Call Default Methods
Consider:
EmailNotification email = new EmailNotification();
This does not work:
email.Log(); // Compile-time error
Instead, call the method through the interface:
INotification notification = email;
notification.Log();
The default implementation is not automatically exposed as a public class member.
Real-Time Example – Extending Existing Interfaces
Suppose an application already has many classes implementing:
public interface IPaymentService
{
void Pay(decimal amount);
}
A new feature requires logging.
Instead of modifying every class, we can extend the interface:
public interface IPaymentService
{
void Pay(decimal amount);
void LogPayment()
{
Console.WriteLine("Payment operation logged");
}
}
Existing implementations can continue working without adding LogPayment().
Interface Before and After C# 8
|
Before C# 8 |
C# 8 |
|---|---|
|
Interface methods generally had no bodies |
Supports default method bodies |
|
Adding an abstract method breaks implementations |
Default implementation can preserve compatibility |
|
Shared behavior usually required other mechanisms |
Limited shared behavior supported in interfaces |
Key Points
-
Introduced in C# 8.0.
-
Interfaces can provide default method implementations.
-
Helps evolve existing interfaces without modifying every implementation.
-
Implementing classes can override the default behavior.
-
Default interface members are not automatically callable through the concrete class type.
-
Runtime support is required; .NET Core 3.0+ and modern .NET support this feature, while .NET Framework does not.
Interview Answer: Default Interface Methods allow interfaces to provide method implementations, helping developers extend existing interfaces while maintaining compatibility with existing implementing classes.
6. Indices and Ranges (^, ..)
What are Indices and Ranges?
C# 8.0 introduced two operators to simplify accessing elements and selecting portions of collections.
-
^– Index from the end. -
..– Range of elements.
These operators make array and string manipulation more readable.
Example Program
using System;
public class Program
{
public static void Main()
{
int[] numbers = { 10, 20, 30, 40, 50 };
Console.WriteLine(numbers[^1]);
Console.WriteLine(numbers[^2]);
int[] selected = numbers[1..4];
foreach (int number in selected)
{
Console.WriteLine(number);
}
}
}
Output:
50
40
20
30
40
Program Explanation
-
numbers[^1]accesses the last element. -
numbers[^2]accesses the second-last element. -
numbers[1..4]selects elements starting at index 1 and ending before index 4. -
The range returns
20, 30, 40. -
The ending index is exclusive.
How Indices Work
Array:
+------+------+------+------+------+
| 10 | 20 | 30 | 40 | 50 |
+------+------+------+------+------+
Forward Index:
0 1 2 3 4
Index From End:
^5 ^4 ^3 ^2 ^1
Important: ^0 refers to the position immediately after the last element, not the last element itself. Therefore, numbers[^0] is invalid for element access.
How Ranges Work
numbers = { 10, 20, 30, 40, 50 }
Index: 0 1 2 3 4
| | | | |
Value: 10 20 30 40 50
<------->
[1..4]
Result = { 20, 30, 40 }
Different Range Examples
|
Syntax |
Meaning |
Result |
|---|---|---|
|
|
Last element |
50 |
|
|
Second-last element |
40 |
|
|
Index 1 to 3 |
20, 30, 40 |
|
|
First 3 elements |
10, 20, 30 |
|
|
Index 2 to end |
30, 40, 50 |
|
|
Entire array |
10, 20, 30, 40, 50 |
|
|
Last 3 elements |
30, 40, 50 |
Real-Time Example – Extracting Part of a String
string orderNumber = "ORD-2026-1001";
string prefix = orderNumber[..3];
string lastFour = orderNumber[^4..];
Console.WriteLine(prefix);
Console.WriteLine(lastFour);
Output:
ORD
1001
Important Internal Concepts
C# 8.0 also introduced two supporting types:
System.Index
System.Range
Example:
Index last = ^1;
Range middle = 1..4;
These types represent positions and ranges. The underlying collection type must support the required indexing or slicing operations.
Key Points
-
Introduced in C# 8.0.
-
^1represents the last element. -
..defines a range with an exclusive ending position. -
Useful for arrays, strings, spans, and supported custom types.
-
Array slicing creates a new array, while slicing a
Span<T>creates a view without copying the underlying elements. -
Improves readability and reduces manual index calculations.
Interview Answer: Indices and Ranges simplify accessing elements from the end and extracting sections of arrays, strings, and other supported collections using ^ and ...
C# 8.0 – Quick Interview Revision
|
Feature |
Main Purpose |
Important Syntax |
|---|---|---|
|
Nullable Reference Types |
Prevent potential null-reference errors |
|
|
Async Streams |
Process asynchronous sequences incrementally |
|
|
Switch Expressions |
Simplify conditional value selection |
|
|
Using Declarations |
Automatically dispose resources |
|
|
Default Interface Methods |
Provide default implementations in interfaces |
|
|
Indices and Ranges |
Simplify indexing and slicing |
|
Most Important Interview Points
For a Senior .NET Developer or Technical Lead interview, focus especially on:
-
Nullable Reference Types – How nullability improves reliability in ASP.NET Core applications and how it differs from runtime validation.
-
Async Streams –
Task<List<T>>versusIAsyncEnumerable<T>and their performance implications. -
Default Interface Methods – How interfaces evolve without breaking existing implementations.
-
Using Declarations – Resource lifetime,
IDisposable, andIAsyncDisposable. -
Switch Expressions – Pattern matching and readable conditional logic.
-
Indices and Ranges – Index-from-end, slicing, and allocation behavior.
These six features are useful for both C# 8.0 interview preparation and modern .NET application development.