C# 15 Union Types: The Pattern Matching Feature Finally Here
TL;DR: C# 15 introduces union types - a compiler-enforced way to represent values that can be one of several types. With exhaustive pattern matching and no inheritance requirements, this feature replaces result patterns, marker interfaces, and complex error handling.
If you've been waiting for C# to catch up with functional programming features, your wait is finally over. C# 15 introduces union types - a language feature that's been requested for years and is now part of .NET 11 preview.
Microsoft recently announced this feature as part of C# 15, and it's already making waves in the developer community. Here's everything you need to know about this game-changing addition.
What Problem Does This Solve?
Imagine writing code where you know a method can return exactly one of several specific types. Currently, C# forces you to choose between:
- Generic
Result<T, E>classes - Exception handling (even for expected errors)
- Complex inheritance hierarchies with marker interfaces
Union types offer a cleaner, more explicit alternative with compile-time exhaustiveness checking.
The Basic Syntax
Declare a union type with the union keyword:
public union Pet(Cat, Dog, Bird);
This creates a Pet union that can hold a Cat, Dog, or Bird. The compiler then ensures that every switch expression handles all three cases.
Real-World Example: API Responses
Let's say you're building an authentication service:
public union AuthResponse(User authenticatedUser, string errorMessage);
AuthResponse Authenticate(string token)
{
if (IsValidToken(token))
{
var user = GetUserFromToken(token);
return new AuthResponse(user, null!);
}
return new AuthResponse(null!, "Invalid or expired token");
}
No more exceptions for expected failures. No more generic result wrappers. Just explicit, typed outcomes.
Exhaustive Pattern Matching
The real power comes from switch expressions that must handle all cases:
AuthResponse response = Authenticate(userToken);
switch (response)
{
case AuthResponse.User user:
Console.WriteLine($"Welcome back, {user.Name}!");
break;
case AuthResponse.ErrorMessage error:
Console.WriteLine($"Error: {error.Message}");
break;
}
Notice what's different: The compiler requires both cases. Miss one? You get a compile-time error. No more runtime surprises from unhandled authentication states.
Replacing Try-Pattern Methods
C# developers are used to the TryGetValue and TryParse pattern:
// The old way
if (dict.TryGetValue(key, out var value))
{
Console.WriteLine(value);
}
else
{
// Handle missing key
}
// Or the new union way
public union GetValueResult<T>(T value, KeyNotFoundError error);
Error Handling Without Exceptions
One of the most compelling use cases is replacing "exceptions as control flow":
// Traditional way - exceptions for expected errors
public User GetUser(string id)
{
var user = _repository.Find(id);
if (user == null)
throw new UserNotFoundException(id);
return user;
}
// Union type way - explicit error types
public union GetUserResult(User user, UserNotFoundException error);
This makes your code more predictable and easier to reason about. Errors become first-class citizens rather than hidden control flow.
Performance Considerations
According to Microsoft's documentation:
- Zero runtime overhead - unions compile to the same IL as equivalent code
- Better type safety at compile time
- Smaller code footprint - no need for inheritance hierarchies
The feature is implemented efficiently, making it production-ready from day one.
Three Primary Use Cases (from Microsoft)
1. Result-or-Error Returns
public union ProcessResult(T success, Error error);
Perfect for operations that have two distinct outcomes.
2. Message or Command Dispatching
public union Command(Login, Logout, UpdateProfile);
With exhaustive matching, you'll get warnings if you add a new command but forget to handle it everywhere.
3. Replacing Marker Interfaces
// Old way - create interface just for pattern matching
public interface IErrorResponse { }
// New way - just use a union
public union ApiResponse(Data, ErrorResponse);
Code Comparison
Before: Generic Result Type
public readonly struct Result<T, E>
{
private readonly T _success;
private readonly E _error;
// Constructor, properties, etc.
}
// Usage everywhere
public Result<User, UserNotFound> GetUser(string id)
After: Union Type
public union GetUserResult(User user, UserNotFound error);
// Simple, explicit, compile-checked
Common Pitfalls to Avoid
1. Don't Overuse
Union types work best with closed sets of 2-10 cases. For larger or dynamic sets, stick with existing patterns.
2. Watch Nullability
Be explicit about which cases can be null:
public union Result(User user, string? error);
3. Keep Cases Small
Each case should represent a single, focused concern. Don't create union LargeComplexType(SmallError, BigProblem).
Best Practices
-
Use descriptive names:
UserResponse(User, ValidationError)beatsResponse(T, string) - Document error cases: XML docs explain when each error occurs
- Group related errors: Keep similar error types together
- Rely on compiler warnings: Let exhaustiveness checking catch missing cases
Migration Path
If you currently use:
-
TryXxxpatterns → Union types with explicit error states -
Generic
Result<T, E>→ Native union syntax - Marker interfaces for pattern matching → Direct unions
The Bottom Line
Union types bring C# one step closer to the type safety of functional languages like F# and Haskell, while maintaining the pragmatic approach C# developers expect.
For developers tired of working around C# limitations in error handling and pattern matching, this feature is a breath of fresh air. It's explicit, safe, and zero-overhead.
Ready to try it? You can experiment with C# 15 union types using the latest Visual Studio 2026 Insiders build or the .NET 11 preview SDK.
What do you think? Are you going to start using union types in your projects? What's the first feature you'd replace with this? Let me know in the comments!
💡 About the author: https://www.linkedin.com/in/vikrant-bagal
Top comments (0)