First of all, union types are unfortunatelly not build into the C# language features. But we can implement something similar.

I personally find it especially useful in combination with the mediator/MediatR pattern/library.

  • gives total transparency of what we can expect as a response
  • we can react very specifically in a typesafe matter
  • easy to test the response by type

// Reuse the same union type in multiple places:
using CommandResponse = OneOf<

// All possible response types:
public record UnauthenticatedResponse;
public record UnauthorizedResponse(Customer CurrentCustomer);
public record PermissionDeniedResponse(Customer CurrentCustomer, Permission permission);
public record CustomerNotFoundResponse(string Email);
public record CustomerEmailAlreadyTakenResponse(Customer Customer);
public record SuccessResponse(Customer UpdatedCustomer);

// The actuall command:
public record ChangeCustomerEmailCommand(int CustomerId, string Email);

// The command handler:
public sealed class ChangeCustomerEmailCommandHandler : IRequestHandler<ChangeCustomerEmailCommand, CommandResponse>
    // Constructor and injected fields omitted for brevity

    public async Task<CommandResponse> Handle(ChangeCustomerEmailCommand command, CancellationToken cancellationToken)
        if (!_authenticationService.IsAuthenticated)
            return new UnauthenticatedResponse();

        if (!_authorizationService.IsOwner(command.CustomerId))
            return new UnauthorizedResponse(_authenticationService.CurrentUser);

        if (!_authorizationService.HasPermission(Permissions.ChangeCustomerEmail))
            return new PermissionDeniedResponse(_authenticationService.CurrentUser, Permissions.ChangeCustomerEmail);

        var customer = await _dbContext.Customers.FirstOrDefault(x => x.Id == command.CustomerId);
        if (customer == null)
            return new CustomerNotFoundResponse(command.Id);

        if (!await _dbContext.Customers.Any(new IsUniqueCustomerEmailSpecification(currentEmail: customer.Email, newEmail: command.Email)))
            return new CustomerEmailAlreadyTakenResponse(customer);

        customer.Email = command.Email;
        await _dbContext.SaveChangesAsync();

        return SuccessResponse(customer);

To consume the command we use an ASP.NET Core controller.

public sealed class ChangeCustomerEmailController
    // Constructor and injected fields omitted for brevity

    public async Task<IActionResult> Handle(ChangeCustomerEmailInputModel inputModel)
        var response = await _mediator.Send(new ChangeCustomerEmailCommand(inputModel.CustomerId, inputModel.Email));

        return await response.Match(
            (UnauthenticatedResponse _) => Challange(),
            (UnauthorizedResponse_) => Challange(),
            (PermissionDeniedResponse) => Challange(),
            (CustomerNotFoundResponse_) => NotFound(),
            (CustomerEmailAlreadyTakenResponse _) => ValidationProblem($"Email {inputModel.Email} already taken."),
            (SuccessResponse _) => Ok());

The nuget package that solves this is called OneOf.

How to implicitly convert to union type?

The OneOf library makes heavily use of the implicit operator. This is how it works.

public record OneOf<T1, T2>(object Result)
    public static implicit operator OneOf(T1 result) => new(Result: result);
    public static implicit operator OneOf(T2 result) => new(Result: result);