Extracting type metadata from Elm code?

Thanks sebbes! I had tried to go the Fuzzer route, but not being able to automatically catch and test new variants seemed like a deal-breaker.

I hadn’t considered the codec approach… keeping the encoder and decoder right next to each other should make it obvious when one’s been missed, and hopefully also when they’re not symmetric (one of them isn’t correct)!

I had a go at a sample codec-style Route:

module RouteCodec exposing (..)

import Url exposing (Url)
import Url.Parser as UP exposing ((</>), Parser, s)


type Route
    = Home
    | Settings
    | User String UserRoute
    | Task Int TaskRoute


type UserRoute
    = Summary
    | Activity


type TaskRoute
    = Active Bool
    | Overdue


type alias Codec a b c =
    { encode : c -> String
    , decode : Parser (a -> b) b
    }


routeToString : Route -> String
routeToString route =
    case route of
        Home ->
            routeCodecs.home.encode ()

        Settings ->
            routeCodecs.settings.encode ()

        User username userRoute ->
            routeCodecs.user.encode ( username, userRoute )

        Task id taskRoute ->
            routeCodecs.task.encode ( id, taskRoute )


routeFromString : String -> Maybe Route
routeFromString string =
    string
        |> (++) "https://x.y/"
        |> Url.fromString
        |> Maybe.andThen (UP.parse routeParser)


routeParser : Parser (Route -> a) a
routeParser =
    UP.oneOf
        [ routeCodecs.home.decode
        , routeCodecs.settings.decode
        , routeCodecs.user.decode
        , routeCodecs.task.decode
        ]


routeCodec : Codec Route a Route
routeCodec =
    { encode = routeToString
    , decode = routeParser
    }


routeCodecs =
    let
        home : Codec Route a ()
        home =
            { encode = always "home"
            , decode = s "home" |> UP.map Home
            }

        settings : Codec Route a ()
        settings =
            { encode = always "settings"
            , decode = s "settings" |> UP.map Home
            }

        user : Codec Route a ( String, UserRoute )
        user =
            { encode =
                \( username, route ) ->
                    "user/" ++ username ++ "/" ++ userRouteCodec.encode route
            , decode =
                (s "user" </> UP.string </> userRouteCodec.decode)
                    |> UP.map User
            }

        task : Codec Route a ( Int, TaskRoute )
        task =
            { encode =
                \( taskId, route ) ->
                    ("task/" ++ String.fromInt taskId ++ "/")
                        ++ taskRouteCodec.encode route
            , decode =
                (s "task" </> UP.int </> taskRouteCodec.decode)
                    |> UP.map Task
            }
    in
    { home = home
    , settings = settings
    , user = user
    , task = task
    }



-------------------------------------------------------------- ↓ UserRoutes.elm


userRouteCodec : Codec UserRoute a UserRoute
userRouteCodec =
    { encode = userRouteToString
    , decode = userRouteParser
    }


userRouteToString : UserRoute -> String
userRouteToString route =
    case route of
        Summary ->
            userRouteCodecs.summary.encode ()

        Activity ->
            userRouteCodecs.activity.encode ()


userRouteParser : Parser (UserRoute -> a) a
userRouteParser =
    UP.oneOf
        [ userRouteCodecs.summary.decode
        , userRouteCodecs.activity.decode
        ]


userRouteCodecs =
    let
        summary : Codec UserRoute a ()
        summary =
            { encode = always "summary"
            , decode = s "summary" |> UP.map Summary
            }

        activity : Codec UserRoute a ()
        activity =
            { encode = always "activity"
            , decode = s "activity" |> UP.map Summary
            }
    in
    { summary = summary
    , activity = activity
    }



-------------------------------------------------------------- ↓ TaskRoutes.elm


taskRouteCodec : Codec TaskRoute a TaskRoute
taskRouteCodec =
    { encode = taskRouteToString
    , decode = taskRouteParser
    }


taskRouteToString : TaskRoute -> String
taskRouteToString route =
    case route of
        Active bool ->
            taskRouteCodecs.active.encode bool

        Overdue ->
            taskRouteCodecs.overdue.encode ()


taskRouteParser : Parser (TaskRoute -> a) a
taskRouteParser =
    UP.oneOf
        [ taskRouteCodecs.active.decode
        , taskRouteCodecs.overdue.decode
        ]


taskRouteCodecs =
    let
        active : Codec TaskRoute a Bool
        active =
            { encode =
                \bool ->
                    if bool then
                        "active"

                    else
                        "inactive"
            , decode =
                UP.oneOf
                    [ s "active" |> UP.map (Active True)
                    , s "inactive" |> UP.map (Active False)
                    ]
            }

        overdue : Codec TaskRoute a ()
        overdue =
            { encode = always "overdue"
            , decode = s "overdue" |> UP.map Overdue
            }
    in
    { active = active
    , overdue = overdue
    }

(gist version here, might be easier to read)

I may have missed something fundamental about Codecs, though… does a record with encode and decode properties meet the definition, or should it be constructed some other way?