Are there any creative ways to work around .NET's "weak" enums when pattern matching? I'd like them to function similarly to DUs. Here's how I currently handle it. Any better ideas?
[<RequireQualifiedAccess>]
module Enum =
let unexpected<'a, 'b, 'c when 'a : enum<'b>> (value:'a) : 'c = //'
failwithf "Unexpected enum member: %A: %A" typeof<'a> value //'
match value with
| ConsoleSpecialKey.ControlC -> ()
| ConsoleSpecialKey.ControlBreak -> ()
| _ -> Enum.unexpected value //without this, gives "incomplete pattern matches" warning
Following the suggestion Stephen made in the comments to his answer, I ended up with the following solution. Enum.unexpected
distinguishes between invalid enum values and unhandled cases (possibly due to enum members being added later) by throwing a FailureException
in the former case and Enum.Unhandled
in the latter.
[<RequireQualifiedAccess>]
module Enum =
open System
exception Unhandled of string
let isDefined<'a, 'b when 'a : enum<'b>> (value:'a) =
let (!<) = box >> unbox >> uint64
let typ = typeof<'a>
if typ.IsDefined(typeof<FlagsAttribute>, false) then
((!< value, System.Enum.GetValues(typ) |> unbox)
||> Array.fold (fun n v -> n &&& ~~~(!< v)) = 0UL)
else Enum.IsDefined(typ, value)
let unexpected<'a, 'b, 'c when 'a : enum<'b>> (value:'a) : 'c =
let typ = typeof<'a>
if isDefined value then raise <| Unhandled(sprintf "Unhandled enum member: %A: %A" typ value)
else failwithf "Undefined enum member: %A: %A" typ value
type MyEnum =
| Case1 = 1
| Case2 = 2
let evalEnum = function
| MyEnum.Case1 -> printfn "OK"
| e -> Enum.unexpected e
let test enumValue =
try
evalEnum enumValue
with
| Failure _ -> printfn "Not an enum member"
| Enum.Unhandled _ -> printfn "Unhandled enum"
test MyEnum.Case1 //OK
test MyEnum.Case2 //Unhandled enum
test (enum 42) //Not an enum member
Obviously, it warns about unhandled cases at run-time instead of compile-time, but it seems to be the best we can do.
I think in general this is a tall order, exactly because enums are "weak". ConsoleSpecialKey
is a good example of a "complete" enum where ControlC
and ControlBreak
, which are represented by 0 and 1 respectively, are the only meaningful values it can take on. But we have a problem, you can coerce any integer into a ConsoleSpecialKey
!:
let x = ConsoleSpecialKey.Parse(typeof<ConsoleSpecialKey>, "32") :?> ConsoleSpecialKey
So the pattern you gave really is incomplete and really does needs to be handled.
(not to mention more complex enums like edit: actually, @ildjarn pointed out that the Flags attribute is used, by convention, to distinguish between complete and bitmask enums, though the compiler won't stop you from using bitwise ops on an enum not marked with this attribute, again revealing the weakens of enums).System.Reflection.BindingFlags
, which are used for bitmasking and yet indistinguishable through type information from simple enums, further complicating the picture
But if you are working with a specific "complete" enum like ConsoleSpecialKey
and writing that last incomplete pattern match case all the time is really bugging you, you can always whip up a complete active pattern:
let (|ControlC|ControlBreak|) value =
match value with
| ConsoleSpecialKey.ControlC -> ControlC
| ConsoleSpecialKey.ControlBreak -> ControlBreak
| _ -> Enum.unexpected value
//complete
match value with
| ControlC -> ()
| ControlBreak -> ()
However that's akin to simply leaving the incomplete pattern match case unhandled and suppressing the warning. I think your current solution is nice and you would be good just to stick with it.
I'd argue that it's a feature of F# that it forces you to handle unexpected values of an enum (since it is possible to create them via explicit conversions, and since additional named values may be added by later versions of an assembly). Your approach looks fine. Another alternative would be to create an active pattern:
let (|UnhandledEnum|) (e:'a when 'a : enum<'b>) =
failwithf "Unexpected enum member %A:%A" typeof<'a> e
function
| System.ConsoleSpecialKey.ControlC -> ()
| System.ConsoleSpecialKey.ControlBreak -> ()
| UnhandledEnum r -> r
Here the process of matching against the UnhandledEnum pattern will throw an exception, but the return type is variable so that it can be used on the right hand side of the pattern no matter what type is being returned from the match.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With