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?