Pattern Matching Analyzers
Pattern matching analyzers ensure exhaustive case handling in switch expressions, switch statements, and Match() calls. They prevent runtime exceptions caused by unhandled union cases.
π― Overviewβ
UnionGenerator provides two pattern matching analyzers:
| Diagnostic ID | Name | Severity |
|---|---|---|
| UG1001 | Incomplete pattern matching in Match() | Warning |
| UG002 | Missing union case in switch | Warning |
Both analyzers detect when you don't handle all union cases and there's no catch-all pattern (_ or default).
UG1001: Incomplete Match() Callsβ
What It Detectsβ
Missing handler parameters in .Match() method calls.
Example Problemβ
[GenerateUnion]
public partial record Result<T>
{
public static partial Result<T> Success(T value);
public static partial Result<T> Error(string message);
}
var result = GetResult();
// β UG1001: Missing 'error' parameter
var output = result.Match(
success: value => $"Got: {value}"
);
Diagnostic Messageβ
Not all union cases are handled in pattern matching. Missing case(s): Error.
How to Fixβ
Option 1: Handle all cases explicitly
// β
All cases handled
var output = result.Match(
success: value => $"Got: {value}",
error: message => $"Failed: {message}"
);
Option 2: Use default parameter (if available)
// β
Use default handler
var output = result.Match(
success: value => $"Got: {value}",
@default: () => "Unknown result"
);
Why This Mattersβ
Without exhaustive checking, adding new union cases becomes dangerous:
// Later, someone adds:
public static partial Result<T> Pending();
// β Your existing code now has a bug - Pending case unhandled!
var output = result.Match(
success: value => $"Got: {value}",
error: message => $"Failed: {message}"
// Runtime exception if result is Pending!
);
UG002: Missing Switch Casesβ
What It Detectsβ
Switch expressions or statements that don't handle all union cases and lack a discard/default arm.
Example Problemsβ
Switch Expressionβ
[GenerateUnion]
public partial record PaymentStatus
{
public static partial PaymentStatus Pending();
public static partial PaymentStatus Completed(string txId);
public static partial PaymentStatus Failed(string reason);
}
var status = GetPaymentStatus();
// β UG002: Missing 'Failed' case
var message = status switch
{
{ IsPending: true } => "Processing...",
{ IsCompleted: true } completed => $"Done: {completed.Value}"
// No Failed case and no discard arm!
};
Switch Statementβ
// β UG002: Missing 'Completed' case
switch (status)
{
case { IsPending: true }:
Console.WriteLine("Pending");
break;
case { IsFailed: true } failed:
Console.WriteLine($"Error: {failed.Value}");
break;
// No default case!
}
Diagnostic Messageβ
Switch on union 'PaymentStatus' does not handle all cases. Missing: Failed. Consider adding a pattern for the missing case(s) or a discard/default arm ('_').
How to Fixβ
Option 1: Handle all cases explicitly
// β
All cases covered
var message = status switch
{
{ IsPending: true } => "Processing...",
{ IsCompleted: true } completed => $"Done: {completed.Value}",
{ IsFailed: true } failed => $"Error: {failed.Value}"
};
Option 2: Add discard arm
// β
Use discard for remaining cases
var message = status switch
{
{ IsPending: true } => "Processing...",
{ IsCompleted: true } completed => $"Done: {completed.Value}",
_ => "Unknown" // Handles Failed and future cases
};
Option 3: Add default case (statements)
// β
Default case added
switch (status)
{
case { IsPending: true }:
Console.WriteLine("Pending");
break;
case { IsFailed: true } failed:
Console.WriteLine($"Error: {failed.Value}");
break;
default:
Console.WriteLine("Other");
break;
}
Detection Modesβ
The analyzer detects incomplete matching in:
1. Property Pattern Matchingβ
status switch
{
{ IsPending: true } => ...,
{ IsCompleted: true } => ...
// Missing IsFailed check
};
2. Type Pattern Matching (C# 9+)β
status switch
{
{ IsPending: true } pending => ...,
{ IsCompleted: true } completed => ...
// Missing Failed pattern
};
3. If-Else Chainsβ
// β οΈ Also detected in if-else chains
if (status.IsPending)
{
// Handle pending
}
else if (status.IsCompleted)
{
// Handle completed
}
// Missing else for Failed case!
π§ Configurationβ
Change Severityβ
In .editorconfig:
# Treat incomplete matching as error
dotnet_diagnostic.UG1001.severity = error
dotnet_diagnostic.UG002.severity = error
Disable for Specific Codeβ
#pragma warning disable UG1001, UG002
var result = status switch
{
{ IsPending: true } => "Pending",
_ => "Other" // Intentionally generic
};
#pragma warning restore UG1001, UG002
Global Disable (Not Recommended)β
In .editorconfig:
# Disable pattern matching warnings
dotnet_diagnostic.UG1001.severity = none
dotnet_diagnostic.UG002.severity = none
Disabling these analyzers removes compile-time safety and makes refactoring dangerous.
π Performance Impactβ
These analyzers have minimal performance impact:
- Run only on switch expressions/statements and Match() calls
- Use efficient symbol-based analysis
- Skip generated code automatically
- Enable concurrent execution
Typical overhead: <50ms per thousand lines of code.
π‘ Best Practicesβ
1. Always Handle All Cases Explicitlyβ
// β
Preferred: Explicit handling
var result = status.Match(
pending: () => "Pending",
completed: tx => $"Success: {tx}",
failed: reason => $"Error: {reason}"
);
// β οΈ Avoid: Generic catch-all
var result = status.Match(
pending: () => "Pending",
@default: () => "Other" // Loses type information
);
2. Use Discard Only When Appropriateβ
Discard arms are acceptable when:
- Multiple cases should have identical handling
- You genuinely don't care about distinguishing cases
- Handling "any other case" is semantically correct
// β
Legitimate use of discard
var isActive = status switch
{
{ IsPending: true } => true,
{ IsCompleted: true } => true,
_ => false // Any other case means inactive
};
3. Review Warnings When Adding Casesβ
When adding new union cases:
- Build the solution
- Review all UG1001/UG002 warnings
- Update each match site appropriately
- Don't blindly add
_ => ...to silence warnings
4. Document Intentional Suppressionsβ
// This API deliberately returns a generic message for all errors.
// Specific error details are logged separately.
#pragma warning disable UG1001
return result.Match(
success: data => data,
@default: () => throw new InvalidOperationException("Operation failed")
);
#pragma warning restore UG1001
π Common False Positivesβ
Nested Switchesβ
The analyzer may not detect exhaustiveness in nested switch expressions:
// May trigger UG002 even though all cases are covered
var result = (outerStatus, innerStatus) switch
{
({ IsPending: true }, _) => "Outer pending",
(_, { IsPending: true }) => "Inner pending",
({ IsCompleted: true }, { IsCompleted: true }) => "Both completed",
// Complex nesting may confuse analyzer
};
Workaround: Add explicit _ => throw new UnreachableException() arm.
Generic Union Types with Constraintsβ
[GenerateUnion]
public partial record Option<T> where T : class
{
public static partial Option<T> Some(T value);
public static partial Option<T> None();
}
// May not always detect exhaustiveness in generic contexts
Workaround: Ensure all cases are explicitly handled.
π Technical Detailsβ
How Detection Worksβ
- Symbol Analysis: Identifies union types by
[GenerateUnion]attribute - Case Discovery: Enumerates all static factory methods as union cases
- Pattern Analysis: Parses switch arms/Match parameters
- Coverage Check: Compares handled cases vs. defined cases
- Diagnostic Reporting: Reports missing cases if no catch-all exists
Limitationsβ
- Indirect matching: Analyzer doesn't track if-else chains that call helper methods
- Runtime cases: Cases determined at runtime can't be analyzed statically
- External unions: Only analyzes unions defined in the current compilation
π Related Topicsβ
- Factory Method Analyzers - Validates union definitions
- Code Fixes - Automatic exhaustiveness fixes
- Pattern Matching Guide - Pattern matching features
π Examplesβ
Complete Real-World Exampleβ
[GenerateUnion]
public partial record ApiResult<T>
{
public static partial ApiResult<T> Success(T data);
public static partial ApiResult<T> NotFound();
public static partial ApiResult<T> Unauthorized();
public static partial ApiResult<T> ServerError(string message);
}
public IActionResult HandleResult<T>(ApiResult<T> result)
{
// β
All cases handled - no warning
return result.Match(
success: data => Ok(data),
notFound: () => NotFound(),
unauthorized: () => Unauthorized(),
serverError: msg => StatusCode(500, msg)
);
}
public string GetStatusMessage<T>(ApiResult<T> result)
{
// β
Discard is appropriate here - all errors treated same
return result switch
{
{ IsSuccess: true } => "Operation succeeded",
_ => "Operation failed"
};
}
public void LogResult<T>(ApiResult<T> result)
{
// β οΈ UG002: Missing ServerError case
switch (result)
{
case { IsSuccess: true } success:
_logger.LogInformation("Success: {Data}", success.Value);
break;
case { IsNotFound: true }:
_logger.LogWarning("Not found");
break;
case { IsUnauthorized: true }:
_logger.LogWarning("Unauthorized");
break;
// Missing ServerError case - analyzer warns!
}
}
π¨ Troubleshootingβ
Warning Doesn't Appearβ
Check:
- Analyzer package is installed
- Build is not suppressing warnings
- Diagnostic severity is not set to
none
Verify:
dotnet build --verbosity diagnostic | grep "UG1001\|UG002"
False Positive Warningsβ
Report an issue: GitHub Issues
Include:
- Code sample reproducing the issue
- Expected behavior
- Actual diagnostic message