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.