Description
I'm experimenting with better typescript support for sum-type and superouter. I spent a few hours in the typescript playground trying different things and eventually got two ideas working.
Here's a public link to a secret gist where I'm tracking the idea https://gist.github.com/JAForbes/7fbd05df701069e097fbe38373c21e9b
The first idea is to have a typed constructor that returns a tagged template literal:
let Route =
type('Route', {
Messages: (o: { message_id: string }) => Path`/messages/${o.message_id}`,
Home: () => Path`/`,
Wow: (o: { todo_id: string }) => Path`/todos/${o.todo_id}`
})
Route.Messages({ message_id: 'hello' }).value.message_id
Route.Home()
Route.Wow({ todo_id: '1' }).value.todo_id
This type checks really well, and allows for discriminated checks, e.g. .value.message_id
type checks for Route.Messages
, but not Route.Wow
The idea behind the tagged template literal is to get away from express style patterns and instead parse the pattern at initialization and then compare a URL against the raw parts. It isn't strictly nessecessary, maybe express patterns are good.
But I found I can actually get back a typed tuple from Path
which is probably useful generally:
let Path = function<
T extends Array<U>
, U=unknown
>(strings : TemplateStringsArray, ...args: T ) {
return { strings, args }
}
let x = Path`/messages/${4}/${'hello'}`
type T = typeof x.args
// [number, string]
I thought it was pretty cool you can extract a tuple out instead of getting back (string | number)[]
.
Next idea, which is my favourite is to infer the constructor from a tagged template expression. There's a bit of noise because it requires as const
for each value. But I think the payoff is worth it.
let Route =
type('Route', {
Messages: Path`/messages/${'message_id' as const}`,
Home: Path`/`,
Wow: Path`/todos/${'todo_id' as const}`
})
let p = Path`/messages/${'message_id' as const}`
p.r.message_id
Route.Messages({ message_id: '4' }).value.message_id // ✅
Route.Messages({ massage_id: '4' }).value.message_id // ❌
Route.Messages({ message_id: '4' }).value.massage_id // ❌
This approach infers the keys of the constructor input object from the Path
expression. It is annoying you need to use as const
but I am hoping that in a future TS version that won't be required, because in that case, as const should be automatically inferred (passing in a string literal should always default to as const imo).
I'm thinking this + nested routers is worth a v1.
I think for JS codebases, I'd still support /messages/:message_id
as Path`/messages/${'message_id'}`
isn't giving you any benefit in JS and its just extra effort for no pay off.