Enum helper package?

#1

I made this module to reduce boilerplate when dealing with enums.

To create an Enum, you first define your custom type:

type Fruit
    = Apple
    | Banana
    | Pineapple

And then you call Enum.create with a list of your variants, and a toString function:

enum : Enum Fruit
enum =
    Enum.create
        [ Apple
        , Banana
        , Pineapple
        ]
        (\a ->
            case a of
                Apple ->
                    "Apple"

                Banana ->
                    "Banana"

                Pineapple ->
                    "Pineapple"
        )

This gives you an Enum a which is a record containing common helper functions, as well as dict and list of the variants, which are useful for things like <select> elements.

type alias Enum a =
    { toString : a -> String
    , fromString : String -> Maybe a
    , encode : a -> Value
    , decoder : Decoder a
    , dict : Dict String a
    , list : List ( String, a )
    }

So you can then use these like Fruit.enum.decoder etc.

Curious if you think it’s worth releasing as a package, and what you think of this idea of returning a record of helper functions?

Currently it only supports strings, since that’s how our enums are encoded in JSON. If I were to release it I’d probably extend it to Ints as well.

Here’s the full code of the module, really short actually:

module Enum exposing (Enum, create)

import Dict exposing (Dict)
import Json.Decode as Decode exposing (Decoder, Value)
import Json.Encode as Encode


type alias Enum a =
    { toString : a -> String
    , fromString : String -> Maybe a
    , encode : a -> Value
    , decoder : Decoder a
    , dict : Dict String a
    , list : List ( String, a )
    }


create : List a -> (a -> String) -> Enum a
create list toStr =
    let
        list2 =
            list |> List.map (\a -> ( toStr a, a ))

        dict =
            Dict.fromList list2
    in
    { toString = toStr
    , fromString = \string -> Dict.get string dict
    , encode = toStr >> Encode.string
    , decoder =
        Decode.string
            |> Decode.andThen
                (\string ->
                    case Dict.get string dict of
                        Just a ->
                            Decode.succeed a

                        Nothing ->
                            Decode.fail ("Missing enum: " ++ string)
                )
    , dict = dict
    , list = list2
    }

3 Likes
#2

A package for that would be really useful! I played around with it and found that maybe a tuple of a string and a constructor is more compact than passing a toString function:

enum : Enum Fruit
enum =
    create
        [ ( "Apple", Apple )
        , ( "Banana", Banana )
        , ( "Pineapple", Pineapple )
        ]

There are some pros and cons to this approach, it optimizes for compactness but it prevents matching a wildcard, for example. What do you think?

#3

It looks nicer, but the reason I didn’t go for that is because then enum.toString would kind of have to return a Maybe String.

Although maybe it would be acceptible to return a default string like "Missing enum" instead? :thinking: Because you would only encounter that situation if you forgot to add a variant to the list, which could still happen with the List + toString design.

So yeah maybe I will go with that actually!

1 Like
#4

It will also be cool to have index-based decoding/encoding. I usually encode to strings because my Haskell back-end also works with ADTs, but I would imagine most traditional back-ends expect numbers instead of strings i.e. first member is 0, second is 1 and so on.

1 Like
#5

I like the case based thing, because it gives a compile time error if all types aren’t accounted for - like if you add a new Fruit later, or remove one. For the same reason I’m more in favor or strings rather than index numbers for encoding, because its more future proof.

One thing that might be handy would be a function to make a list of all the enums. There was a thread about this a while back. A function nextType could be used to recurse through all the types, and give a compile error if a case is missing, or a type was deleted.

3 Likes
#6

It’s a bit of a shame that Elm 0.19 removed top level destructuring – this would have been very nice with that.

module Fruit exposing (Fruit(..), encode, decoder, toString, fromString)

import Enum

type Fruit
     = Apple 
      | Banana
      | Pineapple

-- doesn't work :(
{ encode, decoder, toString, fromString } =
    Enum.create
        [ Apple
        , Banana
        , Pineapple
        ]
        (\a ->
            case a of
                Apple ->
                    "Apple"

                Banana ->
                    "Banana"

                Pineapple ->
                    "Pineapple"
        )

I suppose you can still do:


module Fruit exposing (Fruit(..), encode, decoder, toString, fromString)

import Enum

type Fruit
     = Apple 
      | Banana
      | Pineapple

enum =
    Enum.create
        [ Apple
        , Banana
        , Pineapple
        ]
        (\a ->
            case a of
                Apple ->
                    "Apple"

                Banana ->
                    "Banana"

                Pineapple ->
                    "Pineapple"
        )

toString : Fruit -> String
toString = 
     enum.toString

-- etc

-- etc
#7

@gampleman What I’ve been doing (since I’m already using this module myself) is something like:

module Order exposing (Fruit(..), fruit)

type alias Order =
    { id: Int
    , fruit: Fruit
    }

type Fruit
    = Apple
    | Banana
    | Pineapple

fruit : Enum Fruit
fruit =
    Enum.create
        [ ( "Apple", Apple )
        , ( "Banana", Banana )
        , ( "Pineapple", Pineapple )
        ]

And then I access the functions like fruit.decoder, so it’s like a psuedo-submodule :slightly_smiling_face:

@progger Hmm, it’s not as readable though, and you could still “link” the variants in the chain incorrectly. I could include it as an alternative constructor though.

Btw. Something that would be pretty cool is if elm-test could get some magic introspection powers to fuzz test custom types! That could be the ultimate solution to this issue.

Wether you base them on Strings or Ints might depend on the backend API you’re using, so it’s still good to have Int as an option.

#8

The module looks nice :sunny: I’m going to try this approach.

I’m currently doing this, as I wanted the exhaustive type-check. I think having this approach as a separate constructor in your module would be nice :ok_hand:

1 Like
#9

Returning a record of function seems a bit odd, but why not if the type system allows it? I actually rather like it.

I tried checking the package site to see if I could find precedent for this approach, just randomly checking a few places I though I might have seen it before, but I could not find any. Would be interested to hear if there are other examples of this kind of thing.

#10

elm-css actually did something like that waaay back.

#11

If you wanted to avoid returning a record of functions you could use an opaque type like this:

module Enum exposing (Enum, encode, decoder, toString, fromString)

type Enum a =
    Enum
        { toString : a -> String
        , fromString : String -> Maybe a
        , encode : a -> Value
        , decoder : Decoder a
        , dict : Dict String a
        , list : List ( String, a )
        }

toString : Enum a -> a -> String
toString helper val = 
    helper.toString val

fromString : Enum a -> String -> Maybe a
... and so on

Can’t really see this being nicer to use though.

#12

So here’s the first draft of the docs! :rocket:
https://elm-doc-preview.netlify.com/Enum?repo=herteby%2Fenum&version=master

And here’s the source: https://github.com/Herteby/enum/blob/master/src/Enum.elm

Would love some input!

Currently I have 4 different constructors for EnumInt which seems a bit much? :thinking: Which ones should I drop?

Also could someone please help tidy up the code for iterate?
I ran into an issue where the item which should be first in the list appeared last, and the code I wrote to fix it isn’t very pretty :sweat_smile:

#13

I would drop the *index versions of the EnumInt constructors. IMHO, it is generally a very bad idea to generate encodings based on the implicit enum index, since the indices could very easily change by accident, breaking the encoded data.

Implicit indices force you to make sure the enums are defined in exactly the same way on all sides, that there are no language-specific quirks happening, and that you never reorder or remove a value. Protobuffer enums for example force you to specify the values explicitely.

Here is a simplified version of the iterate function based on your iterate2 function, and made tail-recursive:

iterate : (a -> (b, a)) -> a -> List ( b, a )
iterate iterator init =
    let
        helper : a -> List ( b, a ) -> List ( b, a )
        helper prevValue stack =
            let
                item =
                    iterator prevValue
                    
                value = 
                    Tuple.second item
            in
            if value == init then
                -- we get our starting value last, so we first reverse the list
                -- we built, and then add the starting value to the front.
                -- If the order is not actually important, we can also just
                -- return `item :: stack` here
                item :: (List.reverse stack)
                
            else
                helper value (item :: stack)
    in
    helper init []
1 Like
#14

@jreusch Yeah removing the implicit index constructors is probably best, I felt a bit uneasy about them too.

And thank you very much for the improved function! :slightly_smiling_face:

I’m also thinking about removing .dict. Nothing wrong with it but I mostly just threw it in because I could. I think you’re far more likely to use .list for things like <select>, and you can ofc just use Dict.fromList.

#15

1.0.0 released!

https://package.elm-lang.org/packages/Herteby/enum/latest/

4 Likes
closed #16

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.