F# Discriminated Union ↔ C# Interoperability: Exhaustiveness Checking via Generated IsCaseName Properties
0 reputation · 02 May 2024, 15:37 UTC
0 reputation · 02 May 2024, 15:37 UTC
Goal: assess whether the bool IsCaseName properties generated for each F# discriminated union case can be used in C# to obtain compile‑time exhaustiveness similar to native F# pattern matching.
Constraint: the properties are runtime booleans; C# switch statements cannot infer exhaustiveness from them, and there is no built‑in way to enforce that all cases are handled without a default fallback.
Uncertainty: whether exposing an underlying discriminator (e.g., an integer tag) or adding a custom pattern‑matching helper could close the gap, and what impact such changes would have on binary compatibility across assembly versions.
Specific questions: Can a C# switch expression be written to guarantee handling of all DU cases using only the IsCaseName properties without a runtime fallback? Is there a way to expose a discriminator value to enable exhaustiveness checking? What compiler attributes or code‑generation strategies could bridge this gap while preserving binary compatibility?
35275 reputation · 03 May 2024, 01:54 UTC
No. The Is<CaseName> boolean properties generated by the F# compiler cannot give you compile-time exhaustiveness in a C# switch expression. They are ordinary runtime properties; the C# compiler treats each as an independent bool and cannot prove mutual exclusivity or that every union case is covered. A switch expression over these booleans will always require a default fallback or discard pattern, and the compiler will not emit CS8509 for missing cases.
F# compiles a discriminated union (DU) to an abstract base class with a concrete subclass per case. For each case it emits a read-only Is<CaseName> property that returns true only for that subclass. From C# you can write:
if (du.IsCaseA) { ... }
else if (du.IsCaseB) { ... }
else { /* fallback required */ }But a switch expression like:
var result = du switch {
{ IsCaseA: true } => HandleA(),
{ IsCaseB: true } => HandleB(),
_ => Throw() // compiler forces this
};does not let the compiler verify that IsCaseA and IsCaseB cover all possibilities. The properties are not a single discriminant, so exhaustiveness checking is impossible.
The reliable way to get CS8509 when a case is missing is to expose an explicit enum discriminator from the F# type and switch on that in C#.
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
member this.Tag =
match this with
| Circle _ -> ShapeTag.Circle
| Rectangle _ -> ShapeTag.Rectangle
and ShapeTag =
| Circle = 0
| Rectangle = 1var area = shape.Tag switch {
ShapeTag.Circle => Math.PI * GetRadius(shape) * GetRadius(shape),
ShapeTag.Rectangle => GetWidth(shape) * GetHeight(shape),
// missing case -> CS8509
};With every enum value handled and no _ or default, the C# compiler guarantees exhaustiveness.
Is<CaseName> properties—the new property simply appears and old code continues to load.Tag field is unsafe: it is an F# compiler implementation detail, may change between compiler versions, and is not guaranteed public or stable.enum discriminator is a source change on the F# side but preserves binary compatibility for callers that already use Is<CaseName> properties. Serialization and reflection consumers must be reviewed because the new member appears in the public API.Is<CaseName> property names and whether a public Tag property exists.Is<CaseName> booleans; confirm the compiler requires a fallback.enum discriminator, switch on it in C#, omit a case, and verify CS8509 is emitted.Is<CaseName> properties without recompilation.Which F# compiler version and --targetframework are you targeting? The visibility and naming of any generated Tag member can vary, so confirming the exact compiled output for your toolchain avoids relying on undocumented behavior.
Use comments to ask for clarification. Post a solution as an answer.
35,275 reputation · 03 May 2024, 03:04 UTC
The F# compiler emits one abstract base type for the DU with a sealed subclass per case, and a public bool IsCaseName getter on the base that each subclass overrides to return true only for its own case.
Because C# switch expressions and pattern matching need a single discriminating expression, testing independent IsCaseName properties is just a series of boolean checks. The compiler therefore cannot prove that the set of checks covers all cases, so a default arm is required and no compile-time exhaustiveness warning is produced.
There is no public integer tag on the emitted type by default; case information is only accessible at runtime via Microsoft.FSharp.Reflection helpers.