← Back to Articles         
C# 8.0 – Important Features Explained

C# 8.0 – Important Features Explained

Published on 12 Oct 2026     13 min read C#
C# 8 - Features

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:

  • string means the variable is intended to contain a non-null value.

  • string? means the variable can contain a string or null.

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

  1. #nullable enable enables nullable reference type analysis.

  2. string name declares a non-nullable reference variable.

  3. string? city explicitly allows null.

  4. The if condition checks whether city is null before accessing Length.

  5. This helps prevent runtime NullReferenceException errors.

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

string name = null; allowed without a nullability warning

Compiler produces a warning

No nullable reference annotations

Supports string?

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:

  • Name is intended to be non-null.

  • Email is 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 .csproj file.

  • Nullable reference types are primarily a compile-time feature, not runtime null protection.

  • string? and string are 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

  1. IAsyncEnumerable<int> represents an asynchronous sequence of integers.

  2. Task.Delay(1000) simulates an asynchronous operation.

  3. yield return i produces one number at a time.

  4. await foreach asynchronously requests and processes each number.

  5. 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

Task<List<T>>

IAsyncEnumerable<T>

Result

Entire collection

Items sequentially

Processing

Usually after completion

As items arrive

Memory

Often holds all results

Can reduce buffering

Consumption

await

await foreach

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, and await 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

  1. status switch evaluates the status variable.

  2. 1 => "Pending" returns Pending when status equals 1.

  3. 2 => "Approved" returns Approved when status equals 2.

  4. _ is a discard pattern that matches any remaining value.

  5. 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 switch (value)

Uses value switch

Uses case

Uses =>

Often uses break or return

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 of case.

  • _ represents a fallback pattern.

  • Supports pattern matching and when guards.

  • 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 IDisposable resources

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

  1. using StreamWriter writer creates a resource managed by a using declaration.

  2. WriteLine() writes text into the file.

  3. The resource remains available until the enclosing scope ends.

  4. When Main() exits, Dispose() is automatically called.

  5. 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

using (...) { }

using Type variable = ...;

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 using with IAsyncDisposable.

  • 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

  1. INotification defines an abstract method Send().

  2. Log() has a default implementation inside the interface.

  3. EmailNotification must implement Send().

  4. EmailNotification does not need to implement Log().

  5. When called through INotification, the default Log() 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

  1. numbers[^1] accesses the last element.

  2. numbers[^2] accesses the second-last element.

  3. numbers[1..4] selects elements starting at index 1 and ending before index 4.

  4. The range returns 20, 30, 40.

  5. 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

numbers[^1]

Last element

50

numbers[^2]

Second-last element

40

numbers[1..4]

Index 1 to 3

20, 30, 40

numbers[..3]

First 3 elements

10, 20, 30

numbers[2..]

Index 2 to end

30, 40, 50

numbers[..]

Entire array

10, 20, 30, 40, 50

numbers[^3..]

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.

  • ^1 represents 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

string? name

Async Streams

Process asynchronous sequences incrementally

IAsyncEnumerable<T>

Switch Expressions

Simplify conditional value selection

value switch { }

Using Declarations

Automatically dispose resources

using var resource = ...;

Default Interface Methods

Provide default implementations in interfaces

void Method() { }

Indices and Ranges

Simplify indexing and slicing

^1, 1..4

Most Important Interview Points

For a Senior .NET Developer or Technical Lead interview, focus especially on:

  1. Nullable Reference Types – How nullability improves reliability in ASP.NET Core applications and how it differs from runtime validation.

  2. Async Streams – Task<List<T>> versus IAsyncEnumerable<T> and their performance implications.

  3. Default Interface Methods – How interfaces evolve without breaking existing implementations.

  4. Using Declarations – Resource lifetime, IDisposable, and IAsyncDisposable.

  5. Switch Expressions – Pattern matching and readable conditional logic.

  6. 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.

← Back to Articles