Tuesday, July 21, 2026

C# 15: Brand New Union Types

In this post, let's have a look at Union types in C# and a subtle difference you will notice when you migrate from a closed class hierarchy to a union.

Some time ago I blogged about C# 15.0: Closed Class Hierarchies. There we used the closed keyword to model a fixed set of Shape types and let the compiler enforce exhaustive pattern matching. 

C# 15.0 also introduces union types, which solve a very similar problem: a value that must be exactly one of a fixed set of types, with exhaustiveness checked by the compiler.

I am using .NET 11 Preview 6 (latest as of today, it will change) for this post.

You will also want to set the following in your project file to opt into the latest language features.
<LangVersion>preview</LangVersion>
Let's start with where we left off, the closed hierarchy version.
Shape[] shapes =
[
    new Circle(2),
    new Rectangle(3, 4),
    new Triangle(4, 5),
];

foreach (Shape shape in shapes)
{
    Console.WriteLine($"{shape.GetType().Name}: {Area(shape):0.00}");
}

static double Area(Shape shape) => shape switch
{
    Circle(var r) => Math.PI * r * r,
    Rectangle(var w, var h) => w * h,
    Triangle(var b, var h) => 0.5 * b * h,
};

public closed record class Shape;

public record class Circle(double Radius) : Shape;

public record class Rectangle(double Width, double Height) : Shape;

public record class Triangle(double Base, double Height) : Shape;
And the output.
Circle: 12.57
Rectangle: 12.00
Triangle: 10.00
Nothing surprising here. Each element in the array is really a Circle, a Rectangle or a Triangle that derives from the Shape base type, so shape.GetType().Name reports the concrete type.

Now let's rewrite this using a union. Notice that Circle, Rectangle and Triangle no longer inherit from anything, they are just plain records. The union declaration composes them into a closed set.
public record class Circle(double Radius);

public record class Rectangle(double Width, double Height);

public record class Triangle(double Base, double Height);

public union Shape(Circle, Rectangle, Triangle);
The array initializer, the foreach and the Area switch expression are all untouched, and everything still compiles. That works because an implicit union conversion exists from each case type to the union, so a Circle, Rectangle or Triangle is silently converted to a Shape. But have a look at the output.
Shape: 12.57
Shape: 12.00
Shape: 10.00
The areas are still correct, but GetType().Name now returns Shape for every element. Why?

When you declare a union, the compiler generates a struct that implements IUnion and stores the actual case value in an object? property named Value. So public union Shape(Circle, Rectangle, Triangle); becomes roughly equivalent to this.
[Union]
public struct Shape : IUnion
{
    public Shape(Circle value) => Value = value;
public Shape(Rectangle value) => Value = value;
public Shape(Triangle value) => Value = value; public object? Value { get; } }
So inside the foreach, shape is the generated Shape struct wrapping a case value, not the case value itself. Calling GetType() on it boxes the struct and reports Shape. The Circle, Rectangle or Triangle instance lives inside the Value property.

This is also why the Area switch expression keeps working without any changes. Pattern matching on a union is applied to Value, so the union is transparent to the patterns and Circle(var r), Rectangle(var w, var h) and Triangle(var b, var h) still match.

If you actually want the concrete case type name, reach through Value.
foreach (Shape shape in shapes)
{
    Console.WriteLine($"{shape.Value?.GetType().Name}: {Area(shape):0.00}");
}
And now we are back to the concrete names.
Circle: 12.57
Rectangle: 12.00
Triangle: 10.00
That is the key mental model difference between the two features. A closed class hierarchy is real inheritance, so the runtime identity is the derived type and GetType() sees it. A union is a generated struct over a closed set of case types, so the runtime identity is the union, and you get to the case value through Value (or through pattern matching, which does the unwrapping for you).

Hope this helps.

Happy Coding.

Regards,
Jaliya

Sunday, July 19, 2026

Azure Content Understanding: Classify and Route to Different Analyzers

In this post, let's have a look at how we can classify a document that contains multiple document types, and route each part to a different analyzer, using Azure AI Content Understanding and the .NET SDK.

Say you receive a single PDF that contains an invoice, a bank statement and a loan application, all combined together, something like this:
Combined Document
Now you want each part identified, and you want different fields extracted from each. Content Understanding supports exactly this: a classifier splits the file into segments, and each category can optionally point at its own analyzer that does the field extraction.

Let's have a look on how to achieve this. 

We are going to create two custom analyzers, one for invoices and one for loan applications. Then we are going to create a classifier with three categories, where the third one (Bank_Statement) is deliberately left without an analyzer. That last bit turns out to be the most interesting part of the whole exercise.

First, the packages (versions are the latest as of today, they will change).
dotnet add package Azure.AI.ContentUnderstanding
dotnet add package Azure.Identity
You will need a Microsoft Foundry resource in a supported region, with the required models deployed and set as defaults, and the Cognitive Services User role assigned to yourself. That role is needed even if you own the resource.
using Azure;
using Azure.AI.ContentUnderstanding;
using Azure.Identity;

const string Endpoint = "https://<your-foundry-resource>.services.ai.azure.com";
const string DocumentUrl = "https://github.com/Azure-Samples/azure-ai-content-understanding-python/raw/refs/heads/main/data/mixed_financial_docs.pdf";

var credential = new DefaultAzureCredential();
ContentUnderstandingClient client = new(new Uri(Endpoint), credential);

var suffix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var loanApplicationAnalyzerId = $"loan_application_analyzer_{suffix}";
var invoiceAnalyzerId = $"invoice_analyzer_{suffix}";
var classifierId = $"classifier_{suffix}";
var completionModel = "gpt-5.2";
Now the first custom analyzer for Invoice. Nothing fancy here, just a field schema describing what we want out of an invoice.
// Invoice analyzer.
ContentAnalyzer invoiceAnalyzer = new()
{
    BaseAnalyzerId = "prebuilt-document",
    Description = "Extracts key fields from invoices",
    Models =
    {
        ["completion"] = completionModel
    },
    FieldSchema = new ContentFieldSchema(new Dictionary<string, ContentFieldDefinition>
    {
        ["InvoiceNumber"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Extract,
            Description = "Invoice identifier."
        },
        ["VendorName"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Extract,
            Description = "Name of the vendor issuing the invoice."
        },
        ["TotalAmount"] = new()
        {
            Type = ContentFieldType.Number,
            Method = GenerationMethod.Extract,
            Description = "Invoice total including tax."
        },
    })
};

await CreateAnalyzer(client, invoiceAnalyzerId, invoiceAnalyzer);
Now the second analyzer for Loan Application, same like before, just different fields.
// Loan application analyzer.
ContentAnalyzer loanApplicationAnalyzer = new()
{
    BaseAnalyzerId = "prebuilt-document",
    Description = "Extracts key fields from loan applications",
    Models =
    {
        ["completion"] = completionModel
    },
    FieldSchema = new ContentFieldSchema(new Dictionary<string, ContentFieldDefinition>
    {
        ["ApplicantName"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Extract,
            Description = "Full name of the loan applicant."
        },
        ["LoanAmountRequested"] = new()
        {
            Type = ContentFieldType.Number,
            Method = GenerationMethod.Extract,
            Description = "Total loan amount requested."
        },
        ["LoanPurpose"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Generate,
            Description = "Stated purpose of the loan."
        },
    }),
};

await CreateAnalyzer(client, loanApplicationAnalyzerId, loanApplicationAnalyzer);
Next, the classifier. This is where the routing happens. Note EnableSegment, which is what makes the service split a multi-document file rather than treating it as one document, and AnalyzerId on each category, which is what points a category at an analyzer.
// Classifier: Invoice and Loan application route to their analyzers, Bank statement is classified only.
ContentAnalyzer classifier = new()
{
    BaseAnalyzerId = "prebuilt-document",
    Description = "Splits a multi-type document and routes each part to its analyzer",
    Models =
    {
        ["completion"] = completionModel
    },
    Config = new ContentAnalyzerConfig
    {
        EnableSegment = true,
        ContentCategories =
        {
            ["Invoice"] = new ContentCategoryDefinition
            {
                Description = "Billing documents requesting payment for goods or services, with line items, taxes and totals.",
                AnalyzerId = invoiceAnalyzerId,
            },
            ["Loan_Application"] = new ContentCategoryDefinition
            {
                Description = "Requests for funding, including applicant details, financial history, loan amount and purpose.",
                AnalyzerId = loanApplicationAnalyzerId,
            },
            ["Bank_Statement"] = new ContentCategoryDefinition
            {
                Description = "Statements summarizing account activity over a period, including deposits, withdrawals and balances.",
                // No AnalyzerId: this category is classified but not routed to an analyzer.
            }
        }
    }
};

await CreateAnalyzer(client, classifierId, classifier);
And then we analyze. The sample PDF above conveniently contains all three document types across four pages.
Operation<AnalysisResult> operation = await client.AnalyzeAsync(
    WaitUntil.Completed,
    classifierId,
    inputs: [new AnalysisInput { Uri = new Uri(DocumentUrl) }]);

AnalysisResult result = operation.Value;

// The first content is the whole file. Its segments show how the classifier split it,
// including categories that were not routed to an analyzer.
var wholeDocument = (DocumentContent)result.Contents![0];

Console.WriteLine($"Split into {wholeDocument.Segments?.Count ?? 0} segment(s):");
foreach (DocumentContentSegment segment in wholeDocument.Segments ?? [])
{
    Console.WriteLine($"  {segment.Category,-18} pages {segment.StartPageNumber}-{segment.EndPageNumber}");
}

// Only categories with an AnalyzerId get their own content entry, with extracted fields.
foreach (AnalysisContent content in result.Contents.Where(c => c.Category is not null))
{
    var document = (DocumentContent)content;
    Console.WriteLine($"\n{document.Category} (pages {document.StartPageNumber}-{document.EndPageNumber}) via {document.AnalyzerId}");

    foreach ((string name, ContentField field) in document.Fields)
    {
        Console.WriteLine($"  {name}: {field.Value ?? "(null)"}");
    }
}
And the output.
Split into 3 segment(s):
  Invoice            pages 1-1
  Bank_Statement     pages 2-3
  Loan_Application   pages 4-4

Invoice (pages 1-1) via invoice_analyzer_1784529162
  InvoiceNumber: INV-100
  VendorName: CONTOSO LTD.
  TotalAmount: 110

Loan_Application (pages 4-4) via loan_application_analyzer_1784529162
  ApplicantName: John Smith
  LoanAmountRequested: 25000
  LoanPurpose: Debt Consolidation
Now here is the part that is easy to get wrong. Bank_Statement shows up in the segment list, but it never appears in the second loop. That is not a bug, it is how the response is shaped, and it is much clearer if we look at the raw JSON.
{
  // omitted: id, status
  "result": {
    "analyzerId": "classifier_1784529162",
    "apiVersion": "2025-11-01",
    // omitted: createdAt, stringEncoding, warnings
    "contents": [
      {
        "path": "input1",
        "markdown": "CONTOSO LTD.\n\n# INVOICE\n...",
        "startPageNumber": 1,
        "endPageNumber": 4,
        "unit": "inch",
        "pages": [
          // omitted: pageNumber, angle, width, height for each of the 4 pages
        ],
        "segments": [
          {
            "segmentId": "segment1",
            "startPageNumber": 1,
            "endPageNumber": 1,
            "category": "Invoice"
          },
          {
            "segmentId": "segment2",
            "startPageNumber": 2,
            "endPageNumber": 3,
            "category": "Bank_Statement"
          },
          {
            "segmentId": "segment3",
            "startPageNumber": 4,
            "endPageNumber": 4,
            "category": "Loan_Application"
          }
        ],
        "analyzerId": "classifier_1784529162",
        "mimeType": "application/pdf"
      },
      {
        "path": "input1/segment1",
        "category": "Invoice",
        "markdown": "CONTOSO LTD.\n\n# INVOICE\n...",
        "fields": {
          "InvoiceNumber": {
            "type": "string",
            "valueString": "INV-100",
            "spans": [
              {
                "offset": 90,
                "length": 7
              }
            ],
            "confidence": 0.738,
            "source": "D(1,7.4772,1.3993,8.0103,1.3987,8.0105,1.5459,7.4774,1.5465)"
          },
          "VendorName": {
            "type": "string",
            "valueString": "CONTOSO LTD.",
            "confidence": 0.939
            // omitted: spans, source
          },
          "TotalAmount": {
            "type": "number",
            "valueNumber": 110,
            "confidence": 0.9
            // omitted: spans, source
          }
        },
        "startPageNumber": 1,
        "endPageNumber": 1,
        // omitted: kind, unit, pages, segments
        "analyzerId": "invoice_analyzer_1784529162"
      },
      {
        "path": "input1/segment3",
        "category": "Loan_Application",
        "markdown": "# Contoso Bank Loan Application Form\n...",
        "fields": {
          "ApplicantName": {
            "type": "string",
            "valueString": "John Smith",
            "confidence": 0.981
            // omitted: spans, source
          },
          "LoanAmountRequested": {
            "type": "number",
            "valueNumber": 25000,
            "confidence": 0.745
            // omitted: spans, source
          },
          "LoanPurpose": {
            "type": "string",
            "valueString": "Debt Consolidation",
            "confidence": 0.675
            // omitted: spans, source
          }
        },
        "startPageNumber": 4,
        "endPageNumber": 4,
        // omitted: kind, unit, pages, segments
        "analyzerId": "loan_application_analyzer_1784529162"
      }
    ]
  },
  "usage": {
    "documentPagesStandard": 4,
    "contextualizationTokens": 4000,
    "tokens": {
      "gpt-5.2-input": 7710,
      "gpt-5.2-output": 222
    }
  }
}
Look at the path values. We have input1, input1/segment1 and input1/segment3. There is no input1/segment2. The service numbered all three segments in the parent content, then only emitted content entries for the two that had an analyzer attached. The gap in the numbering is the giveaway.

I could not find this spelled out in the docs, but from what I can see the response has two layers, and you need both:

  • Classification lives in Contents[0].Segments, and contains every category the classifier found, routed or not.
  • Extraction lives in  Contents[1..], and contains only the categories that had an AnalyzerId, each with its Fields.
One last practical note: analyzers are resources that live on your Foundry resource until you delete them, so remember to clean them up.
// Classifier first: it references the two analyzers.
foreach (var analyzerId in new[] { classifierId, invoiceAnalyzerId, loanApplicationAnalyzerId })
{
    try
    {
        await client.DeleteAnalyzerAsync(analyzerId);
    }
    catch (RequestFailedException ex) when (ex.Status == 404)
    {
        // Creation failed earlier, so let that exception surface instead of this one.
    }
}
More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Friday, July 17, 2026

ASP.NET Core 11.0: Async Validation in Minimal APIs

In this post, let's have a look at asynchronous validation support for Minimal APIs, which is going to be available in ASP.NET Core 11.0.

Some time back, I blogged about Validation Support for Minimal APIs and Custom Validation in ASP.NET Core 10.0. This is a continuation of that, but this time the validators run asynchronously.

With the latest .NET 11 Preview 6, Minimal APIs now supports asynchronous validators end-to-end.

Let's look at the two ways to write asynchronous validators.

AsyncValidationAttribute

This is the asynchronous version of a custom ValidationAttribute. We inherit from AsyncValidationAttribute and override IsValidAsync. Here we can resolve the services from the ValidationContext and use those services for our validation logic.
public record Product(
    [Required, UniqueSku] string Sku,
    [Required] string Name);

internal sealed class UniqueSkuAttribute : AsyncValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) =>
        throw new InvalidOperationException($"Validate this attribute with '{nameof(IsValidAsync)}'.");

    protected override async Task<ValidationResult?> IsValidAsync(object? value,
        ValidationContext validationContext,
        CancellationToken cancellationToken)
    {
        ICatalogService catalogService = validationContext.GetRequiredService<ICatalogService>();
        if (value is string sku && await catalogService.SkuExistsAsync(sku, cancellationToken))
        {
            return new ValidationResult("A product with that SKU already exists.");
        }

        return ValidationResult.Success;
    }
}
Note the UniqueSku attribute in Sku. That's the custom attribute we created that performs an asynchronous validation. Here since IsValid in ValidationAttribute is abstract, we have to implement it even though we validate asynchronously. Here it just throws. 

Something to remember: for minimal API validation using Microsoft.Extensions.Validation, the framework always calls the async path and never the sync path. So it's safe to throw.

Now say we have the following API.
using System.ComponentModel.DataAnnotations;
using System.Runtime.CompilerServices;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Add validation services to the container
builder.Services.AddValidation();

builder.Services.AddSingleton<ICatalogService, DummyCatalogService>();

WebApplication app = builder.Build();

app.MapPost("/products", (Product product) =>
    Results.Ok(product));

app.MapPost("/orders", (OrderRequest orderRequest) =>
    Results.Ok(orderRequest));

app.Run();
Just like before, we enable validation by calling AddValidation()

Now let's create a Product with an existing SKU.
@WebApplication1_HostAddress = http://localhost:5016

POST {{WebApplication1_HostAddress}}/products
Content-Type: application/json

{
  "sku": "EXISTING_SKU",
  "name": "Some Product"
}
Throws 400 as expected.
400: Bad Request
IAsyncValidatableObject

This is the asynchronous version of IValidatableObject. This is useful when the validation needs more than one property. We implement ValidateAsync, which returns an IAsyncEnumerable<ValidationResult>.
public record OrderRequest(
    [Range(1, int.MaxValue)] int ProductId,
    [Range(1, int.MaxValue)] int Quantity,
    string PromoCode) : IAsyncValidatableObject
{
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) =>
        throw new InvalidOperationException($"Validate this type with '{nameof(ValidateAsync)}'.");

    public async IAsyncEnumerable<ValidationResult> ValidateAsync(
        ValidationContext validationContext,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        ICatalogService catalogService = validationContext.GetRequiredService<ICatalogService>();

        if (!await catalogService.HasStockAsync(ProductId, Quantity, cancellationToken))
        {
            yield return new ValidationResult(
                $"Product '{ProductId}' does not have '{Quantity}' unit(s) in stock.",
                [nameof(Quantity)]);
        }

        if (!await catalogService.IsPromoCodeValidAsync(PromoCode, cancellationToken))
        {
            yield return new ValidationResult(
                $"Promo code '{PromoCode}' is not valid.",
                [nameof(PromoCode)]);
        }
    }
}
IAsyncValidatableObject extends IValidatableObject, so the synchronous Validate still has to be there. Same as before, it just throws.

Ordering more than what we have in stock and also with an invalid Promo Code,
@WebApplication1_HostAddress = http://localhost:5016

POST {{WebApplication1_HostAddress}}/orders
Content-Type: application/json

{
  "productId": 1,
  "quantity": 100,
  "promoCode": "INVALID"
}
Throws 400 as expected.
400: Bad Request
Hope this helps.

Happy Coding.

Regards,
Jaliya

Thursday, July 16, 2026

Received Microsoft MVP Award in Developer Technologies

I am humbled and honored once again to receive the precious Microsoft Most Valuable Professional (MVP) Award for the 13th consecutive year.

As always looking forward to another great year on top of Microsoft Development Stack.
Microsoft Most Valuable Professional (MVP)
Thank you Microsoft for your appreciation and Thank you everyone for your continuous support.

Happy Coding.

Regards,
Jaliya

Friday, July 3, 2026

.NET Options Validation in Application Startup

In this post, let's have a look at how we can validate options in application startup in a .NET application.

The Options pattern lets us bind a configuration section to a strongly-typed class. On top of that, we can validate the bound values so that a missing/incorrect configuration fails fast at application startup rather than blowing up at some random point at runtime when the options are first used. All of this lives in Microsoft.Extensions.Options, so it works the same in Console apps, Worker Services, ASP.NET Core and any other .NET host. 

Let's have a look at a simple example.

Say we have the following appsettings.json.
{
  "WeatherApi": {
    "BaseUrl": "Something",
    "TimeoutSeconds": 300,
    "Cache": {
      "DurationSeconds": 7200
    }
  }
}
And the following options classes and registration.
using Microsoft.Extensions.Options;
using System.ComponentModel.DataAnnotations;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<WeatherApiOptions>()
    .Bind(builder.Configuration.GetSection(WeatherApiOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();

// Omitted for brevity

WebApplication app = builder.Build();

// Omitted for brevity

app.Run();

public class WeatherApiOptions
{
    public const string SectionName = "WeatherApi";

    [Required]
    [Url]
    public string BaseUrl { get; set; } = string.Empty;

    [Range(1, 60)]
    public int TimeoutSeconds { get; set; }

    [Required]
    [ValidateObjectMembers]
    public CacheOptions Cache { get; set; } = new();
}

public class CacheOptions
{
    [Range(30, 3600)]
    public int DurationSeconds { get; set; }
}
Here ValidateDataAnnotations() validates the DataAnnotation attributes on our options type. By default though, that validation is lazy, it only runs the first time someone accesses IOptions<WeatherApiOptions>.Value. That means a misconfigured application would happily start up and only fail later at runtime when the options are first used. ValidateOnStart() fixes that by forcing the validation to run eagerly at application startup, so we fail fast. (This kicks in as long as something actually starts the host, e.g. app.Run().)

Something to note is, the ValidateDataAnnotations() only validates the top-level options type. It does not recurse into nested objects (or into items of a collection). So the DataAnnotation attributes on CacheOptions are silently ignored. And for that, from .NET 8, onwards, two new attributes have been added to Microsoft.Extensions.Options:
  • [ValidateObjectMembers] - recursively validates the DataAnnotation attributes on a nested object.
  • [ValidateEnumeratedItems] - recursively validates the DataAnnotation attributes on each item of a collection.
The application now fails at startup, and notice that all the validation failures, including the ones on the nested object are reported at once.
Microsoft.Extensions.Options.OptionsValidationException: 

DataAnnotation validation failed for 'WeatherApiOptions' members: 
'BaseUrl' with the error: 'The BaseUrl field is not a valid fully-qualified http, https, or ftp URL.'.;
DataAnnotation validation failed for 'WeatherApiOptions' members:
'TimeoutSeconds' with the error: 'The field TimeoutSeconds must be between 1 and 60.'.;
DataAnnotation validation failed for 'WeatherApiOptions.Cache' members:
'DurationSeconds' with the error: 'The field DurationSeconds must be between 30 and 3600.'.
Hope this helps.

Happy Coding.

Regards,
Jaliya

Tuesday, June 30, 2026

Running Containers on WSL with C#

In this post, let's have a look at WSL Containers, which is now available for public preview, and see how we can programmatically start a container on WSL using the Microsoft.WSL.Containers SDK.

WSL now has native container support to build, pull, and run Linux containers directly on WSL, without Docker Desktop. It ships with a new CLI, wslc, and a set of SDKs for C, C++, and C#.

1. Install the WSL preview and check the version

WSL Containers is only available in the pre-release version for now. Let's update WSL to the pre-release.
wsl --update --pre-release
Once that's done, make sure the version is 2.9.3.0 or later (latest as of today, it will change).
wsl --version

WSL version: 2.9.3.0
Kernel version: 6.18.35.2-1
...

2. wslc

Once you are on 2.9.3.0 or later, you get a new CLI, wslc, which feels very much like the Docker CLI.
wslc
You can pull images, run containers, list them, and so on.
wslc pull alpine:latest
wslc run alpine:latest cat /etc/alpine-release
wslc images
wslc list || wslc container list

3. Starting a container from code with Microsoft.WSL.Containers

Now let's see how we can do this programmatically. Let's create a console application and add the Microsoft.WSL.Containers NuGet package (2.9.3, latest as of today).

One thing to note is the target framework. The package is a C#/WinRT projection, so the project needs to target a Windows flavored framework. The package itself targets net8.0-windows10.0.19041.0, so anything from .NET 8 upwards works. I am using .NET 11 here, which gives me net11.0-windows10.0.19041.0. A plain net11.0 (without the Windows part) will not work.
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0-windows10.0.19041.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.WSL.Containers" Version="2.9.3" />
  </ItemGroup>

</Project>
And here is the code. It starts a Session (a lightweight VM), pulls an image into it, creates and starts a Container, execs a command, and prints its output.
using Microsoft.WSL.Containers;

const string image = "alpine:latest";
var storage = Path.Combine(AppContext.BaseDirectory, "WslcStorage");

// 1. Start a container session (its own lightweight VM).
Console.WriteLine("[wslc] Starting session...");
using var session = new Session(new SessionSettings("HelloWorld", storage));
session.Start();

// 2. Pull an image into the session.
session.PullImage(new PullImageOptions(image));

// 3. Create container.
using Container container = session.CreateContainer(new ContainerSettings(image)
{
    InitProcess = new ProcessSettings { CommandLine = ["/bin/sleep", "infinity"] },
    EnableAutoRemove = true,
});

// 4. Start the container.
container.Start();

// 5. Exec a command and print its output.
using var slim = new ManualResetEventSlim();
using Stream stdout = Console.OpenStandardOutput();
using Process process = container.CreateProcess(new ProcessSettings
{
    CommandLine = [
        "/bin/sh",
        "-c",
        "echo \"Hello from a WSL container running Alpine $(cat /etc/alpine-release)\""
    ],
    OutputMode = ProcessOutputMode.Event,
});
process.OutputReceived += data => stdout.Write(data, 0, data.Length);
process.Exited += _ => slim.Set();
process.Start();
slim.Wait();

// 6. Terminate session.
Console.WriteLine("[wslc] Terminating session...");
session.Terminate();
Console.WriteLine("[wslc] Done.");
And when you run it:
wslc using C#
One thing to note here: the image you pull and the container you create from code live in their own session, which is separate from the session the wslc CLI uses. To see images, containers within a session, you can use,
wslc --session <SESSION_NAME> images
wslc --session <SESSION_NAME> container list
The neat part is that we can now pull and run standard OCI container images (what most of us call Docker images) from any OCI registry, be it Docker Hub, GitHub Container Registry, Azure Container Registry, or others, without having Docker Desktop installed or running at all. So, will this replace Docker? Definitely not anytime soon, but it's handy, and being able to drive it from C# opens up some nice possibilities.

Hope this helps.

Happy Coding.

Regards,
Jaliya

Thursday, June 25, 2026

C# 15.0: Closed Class Hierarchies

In this post, let's have a look at a nice new C# language feature: Closed Class Hierarchies that's shipping as part of C# 15.

I am using .NET 11 Preview 5 (latest as of today, it will change) for this post.

You will also want to set the following in your project file to opt into the latest language features.
 <LangVersion>preview</LangVersion>
Let's start with the problem first. Say I have a simple type hierarchy of shapes and a switch expression that calculates the area.
Shape[] shapes =
[
    new Circle(2),
    new Rectangle(3, 4)
];

foreach (Shape shape in shapes)
{
    Console.WriteLine($"{shape.GetType().Name}: {Area(shape):0.00}");
}

static double Area(Shape shape) => shape switch
{
    Circle(var r) => Math.PI * r * r,
    Rectangle(var w, var h) => w * h
};

public abstract record class Shape;

public record class Circle(double Radius) : Shape;

public record class Rectangle(double Width, double Height) : Shape;
Here I am handling Circle and Rectangle, which are the only two subtypes of Shape that exist. But the compiler still gives me a warning.
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). 
For example, the pattern '_' is not covered.
CS8509: The pattern '_' is not covered
The reason is, even though I have handled every subtype that exists today, the compiler has no way of knowing that. Someone could derive another type from Shape anywhere, so the compiler insists that I add a _ (discard) arm to be safe. And that's exactly the problem: the moment I add a _ => throw ... catch-all to silence this, I lose all compiler help. If I add a new subtype later and forget to handle it, the code happily compiles and falls into the catch-all at runtime.

This is where Closed class hierarchies come in. I just need to mark the base type with the new closed keyword.
// Omitted for brevity

public closed record class Shape;

public record class Circle(double Radius) : Shape;

public record class Rectangle(double Width, double Height) : Shape;
Now the CS8509 warning is gone, and notice I didn't have to add a _ arm at all.

A closed type can only be directly derived from within the same assembly, and it is implicitly abstract. Now the compiler knows the complete set of subtypes, so it can prove the switch expression is exhaustive.

And here is the best part. Let's say the requirements grow and I add a new shape, Triangle, but I forget to update the Area switch.
// Omitted for brevity

public closed record class Shape;

public record class Circle(double Radius) : Shape;

public record class Rectangle(double Width, double Height) : Shape;

public record class Triangle(double Base, double Height) : Shape;
Because Shape is closed, the compiler immediately tells me exactly what I missed.
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). 
For example, the pattern 'Triangle' is not covered.
CS8509: The pattern 'Triangle' is not covered.
Notice the difference: instead of the vague '_' is not covered, it now says 'Triangle' is not covered, naming the exact subtype I forgot, on every switch I need to update.

One thing while this is still in preview. The closed keyword needs a compiler-required attribute that the BCL doesn't ship yet, so you have to hand-roll it. If you don't, you'll get an error like this:
error CS0656: Missing compiler required member 'System.Runtime.CompilerServices.ClosedAttribute..ctor'
Interestingly, the well-known member name drifted across preview toolsets. The .NET SDK CLI compiler (I am on 11.0.100-preview.5) asks for ClosedAttribute, while my Visual Studio toolset asks for IsClosedTypeAttribute. To keep both happy, I just declared both in a separate file.
namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ClosedAttribute : Attribute { }

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class IsClosedTypeAttribute : Attribute { }
This is preview behavior and should get cleaned up as the feature stabilizes, but it's good to know if you want to try it out today.

Hope this helps.

Happy Coding.

Regards,
Jaliya