Skip to content

Instantly share code, notes, and snippets.

@Alexandre-Herve
Last active September 20, 2022 18:51
Show Gist options
  • Save Alexandre-Herve/7b6822d7360a86bb77e46d3a31b17aa4 to your computer and use it in GitHub Desktop.
Save Alexandre-Herve/7b6822d7360a86bb77e46d3a31b17aa4 to your computer and use it in GitHub Desktop.
A Typesafe version of @denisborovikov's routes proxy wrapper
// Tweaked from https://gist.github.com/denisborovikov/3e6e772d9d1897c150639872467d214f
const routes = {
home: '/',
transactions: '/transactions',
transactionDetails: '/transactions/:uuid/:optional?',
transfer: '/transfer/:type(bank|card)?/:id?'
} as const // prevents Typescript from widening the path types to string.
type Routes = typeof routes;
type ExtractUnion<E, Acc extends string = never> = E extends `${infer Head}|${infer Tail}`
? ExtractUnion<Tail, Acc | Head>
: Acc | E
type ExtractEnum<P extends string> = P extends `${infer S}(${infer E})${infer R}`
? ExtractUnion<E>
: string
type RemoveEnum<P extends string> = P extends `${infer H}(${infer Tail}` ? H : P
// Returns a tuple [param_name, requirement, allowed_values]
type Param<Segment extends string> = Segment extends `:${infer P}`
? P extends `${infer O}?`
? [RemoveEnum<O>, 'optional', ExtractEnum<O>]
: [RemoveEnum<P>, 'required', ExtractEnum<P>]
: never
// Reccursively create a union string of all the params in a route path
type ParamsUnion<
Route extends string,
Acc extends [string, 'optional' | 'required', string] = never
> = Route extends `/${infer S}/${infer Rest}`
? ParamsUnion<`/${Rest}`, Acc | Param<S>>
: Route extends `/${infer S}`
? S extends ''
? Acc
: Acc | Param<S>
: Acc
// Creates an object type where all the params must be specified
type Params<Route extends string> = {
[key in ParamsUnion<Route> as (key extends [infer P, 'required', infer V] ? P & string : never)]: key[2];
} & {
[key in ParamsUnion<Route> as (key extends [infer O, 'optional', infer V] ? O & string : never)]?: key[2];
}
// Proxy is not typed to return a different type from the one proxied, we tweak it.
// See: https://github.com/microsoft/TypeScript/issues/20846#issuecomment-582183737
interface RoutesProxyConstructor {
new <T extends {}, H extends object>(target: T, handler: ProxyHandler<T>): H
}
const RoutesProxy = Proxy as RoutesProxyConstructor
// Our returned proxy type
type Urls = {
[R in keyof Routes]: {
route: Routes[R],
get: (params: Params<Routes[R]>) => string
}
}
// Type safe proxying
const urls = new RoutesProxy<Routes, Urls>(routes, {
get<R extends keyof Routes>(target: Routes, propKey: R) {
const route = target[propKey]
return {
get: (params: Params<Routes[R]>) => generatePath(route, params),
route,
}
},
})
console.log(urls.unknown.route)
// ❌
console.log(urls.transactionDetails.get({ unknown: 'wrong' }))
// ❌
console.log(urls.home.get())
// ❌ but should be 🟢
console.log(urls.transactionDetails.get({ uuid: 'uuid' }))
// 🟢
console.log(urls.transactionDetails.get({ uuid: 'uuid', optional: 'allowed' }))
// 🟢
console.log(urls.transfer.get({ type: 'bank' }))
// 🟢
console.log(urls.transfer.get({ type: 'not_in_enum' }))
// ❌
console.log(urls.transfer.get({}))
// 🟢
@denisborovikov
Copy link

denisborovikov commented Sep 20, 2022

In the current implementation, an empty object is required if the URL doesn't have any parameters.

console.log(urls.transactions.get())
// ❌

console.log(urls.transactions.get({}))
// 🟢

Also, it seems much harder to type urls with optional parameters

{ 
  transfer: '/transfer/:type(bank|card)?/:id?'
}

@Alexandre-Herve
Copy link
Author

Alexandre-Herve commented Sep 20, 2022

The optional parameter can be relatively easily handled I think with something like:

type Param<Segment extends string> = Segment extends `:${infer P}`
 ? P extends `${infer O}?`
    ? [O, 'optional']
    : [P, 'required']
 : never

Then change the type of the recursive Acc to: Acc extends [string, 'optional' | 'required'][] = [] and:

type Params<Route extends string> = {
  [key in ParamsUnion<Route> as (key extends [infer P, 'required'] ? P & string : never)]: string;
} & {
  [key in ParamsUnion<Route> as (key extends [infer O, 'optional'] ? O & string : never)]?: string;
}

I updated, dealing with optional and enums.
I'm pretty sure we can deal with the no params issues but I can't do it now, I'll try to find a moment!

@denisborovikov
Copy link

This is super cool 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment