String-backed Enums in F#
Date: 2023-12-27 | create | tech | fsharp | enums |
Recently I've been playing around with server-side rendering in F#. In one experiment I tried out HTMX which utilizes element IDs to uniquely target the HTML element to swap / act on. This means if you get the element ID wrong your page interaction breaks (that's bad!).
Thinking about how to prevent this, I figured a type-safe collection of the finite potential elements I had on my page was a pretty good way to model it. This made me think Discriminated Unions and since the id was a string, why not a string-backed Enum which is essentially a DU with a single value.
type MyStrEnum =
| A = "a-id"
| B = "b-id"
| C = "c-id"
This serves several purposes:
- Creates a type enumerating the page's potential targets
- Allows for match statements and their build-time checks (ensuring you've handled each one)
- Single source of truth for type -> string id and vice versa
Alas F# does not currently support string-backed Enums natively. Functional / performance purists are probably saying "duh, if you are checking the value of an Enum directly you are doing it wrong". But I want to do it anyway (for science, humanity, and simplicity!).
So in this post we'll explore how to define a string-backed Enum in F#:
Q: How to simply define a string-backed Enum in F#?
Answer
I experimented with several ways to try and achieve a string-backed enum-like experience that offers a simple definition and usage pattern:
- Enum -> string
- string -> Enum option
After several iterations I've landed on a pretty good solution that offers a decent balance using string Literals.
In this post we'll explore a couple options and my journey to landing on my current best solution.
A Dictionary Solution
My first solution to this was to use a different type entirely which mapped my int-backed enum to strings and surfaced useful helper methods.
- Define an Enum
- Construct my StrEnum with a match function from Enum -> String
type MainPageTargets =
| FullPage = 0
| SentinelTable = 1
let mainPageTargetsStrEnum = StrEnum<MainPageTargets>(
fun e ->
match e with
| MainPageTargets.FullPage -> "full-page"
| MainPageTargets.SentinelTable -> "sentinel-table"
)
This works fine and is pretty simple to use. But there is a bit of complexity under the hood including lots of Reflection, the requirement for it to be an Enum, and internal processing.
See: Full Source Code
The usage is simple but the logic isn't colocated and it seems like a lot of stuff to just get a simple string-backed Enum.
Asking for Help
So my previous solution worked but it didn't feel good. So it felt like there was a better way but I couldn't think of any.
So I reached out to r/fsharp for help. After lots of discussion and several different solutions, I think I landed on a relatively simple solution that I wouldn't feel too bad using.
Thanks everyone for your inputs!
String-backed Enums with Literals
In this version we are using string Literals which allow us to use these values directly in match cases.
- Good: Match cases are easy to write, no room for mistyping strings ("Bar" vs "bar"), pretty small code
- Bad: The Literal cannot be declared inside the type -> but can usually be declared right next to the type so not so bad
You can play around with this option on Replit.
let [<Literal>] AId = "a-id"
let [<Literal>] BId = "b-id"
let [<Literal>] CId = "c-id"
type MyStrEnum =
| A
| B
| C
member this.AsString() =
match this with
| A -> AId
| B -> BId
| C -> CId
static member AsEnum (s) : MyStrEnum option =
match s with
| AId -> Some A
| BId -> Some B
| CId -> Some C
| _ -> None
Next
This is the best solution I've found yet for string-backed Enums in F#. That said I'm always on the hunt for even simpler solutions (ideally we'd just get these natively in F# (see: F# language suggestion for string enums)). So let me know if you have thoughts for how to do this better so I can further simplify in my future projects!
If you liked this post you might also like:
Want more like this?
The best / easiest way to support my work is by subscribing for future updates and sharing with your network.