Created
May 21, 2020 01:25
-
-
Save bmingles/ebc77ba8f1eb5bb48b8cdd9f99103413 to your computer and use it in GitHub Desktop.
Strong typed routing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export interface Spec<K extends string, T> { | |
ctr: (raw: string) => T, | |
key: K, | |
match: RegExp | |
} | |
type Data<T, D> = { | |
type: T | |
} & { [P in keyof D]: D[P] }; | |
export interface Route< | |
D extends { type: T, path: string }, | |
T extends string = string, | |
S extends Array<Spec<any, any> | string> = Array<Spec<any, any> | string> | |
> { | |
(data: Omit<D, 'type' | 'path'>): D, | |
fromPath: (url: string) => D | undefined, | |
specs: S, | |
type: T | |
} | |
export function as<K extends string, T>(spec: Spec<K, any>) { | |
return spec as Spec<K, T>; | |
} | |
export function specFactory<T>( | |
ctr: (raw: string) => T, | |
match: RegExp | |
) { | |
return function createSpec<K extends string>(key: K): Spec<K, T> { | |
return { | |
ctr, | |
key, | |
match | |
}; | |
}; | |
} | |
const dateMatch = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{1,3}Z?)?)?$/; | |
const numberMatch = /^\d+$/; | |
const stringMatch = /^[^\/]+$/; | |
function caseInsensitive(value: string) { | |
return new RegExp(`^${value}$`, 'i'); | |
} | |
export const date = specFactory(raw => new Date(raw), dateMatch); | |
export const number = specFactory(Number, numberMatch); | |
export const string = specFactory(String, stringMatch); | |
export function toPath( | |
specs: Array<Spec<any, any> | string>, | |
data: { [key: string]: any } | |
) { | |
if(specs.length === 0) { | |
return '/'; | |
} | |
return [ | |
'', | |
...specs.map((spec: Spec<string, any> | string) => { | |
const token = typeof spec === 'string' ? spec : data[spec.key]; | |
if(token instanceof Date) { | |
return token.toISOString(); | |
} | |
return token; | |
}) | |
].join('/'); | |
} | |
// Key / Value helper for composing route data type | |
type KV<A2> = A2 extends Spec<infer K1, infer T1> | |
? { [P in K1]: T1 } | |
: {}; | |
export function createRoute< | |
T extends string, | |
A extends Array<Spec<any, any> | string>, | |
D extends | |
A extends [] ? { type: T, path: string } : | |
A extends [infer A1] ? { type: T, path: string } & KV<A1> : | |
A extends [infer A1, infer A2] ? { type: T, path: string } & KV<A1> & KV<A2> : | |
A extends [infer A1, infer A2, infer A3] ? { type: T, path: string } & KV<A1> & KV<A2> & KV<A3> : | |
A extends [infer A1, infer A2, infer A3, infer A4] ? { type: T, path: string } & KV<A1> & KV<A2> & KV<A3> & KV<A4> : | |
A extends [infer A1, infer A2, infer A3, infer A4, infer A5] ? { type: T, path: string } & KV<A1> & KV<A2> & KV<A3> & KV<A4> & KV<A5> : | |
A extends [infer A1, infer A2, infer A3, infer A4, infer A5, infer A6] ? { type: T, path: string } & KV<A1> & KV<A2> & KV<A3> & KV<A4> & KV<A5> & KV<A6> : | |
A extends [infer A1, infer A2, infer A3, infer A4, infer A5, infer A6, infer A7] ? { type: T, path: string } & KV<A1> & KV<A2> & KV<A3> & KV<A4> & KV<A5> & KV<A6> & KV<A7> : | |
never | |
>(type: T, ...specs: A): Route<D, T, A> { | |
function fromPath(path: string): D | undefined { | |
const tokens = path === '/' | |
? [] | |
: path.split('/').slice(1); | |
if(specs.length !== tokens.length) { | |
return undefined; | |
} | |
const allMatch = specs.every((spec, i) => { | |
const match = typeof spec === 'string' ? caseInsensitive(spec) : spec.match; | |
return match.test(tokens[i]); | |
}); | |
if(!allMatch) { | |
return undefined; | |
} | |
const fields = specs.reduce((memo, spec, i) => { | |
if(typeof spec === 'string') { | |
return memo; | |
} | |
return { | |
...memo, | |
[spec.key]: spec.ctr(tokens[i]) | |
}; | |
}, {}); | |
return { | |
...fields, | |
type, | |
path | |
} as D; | |
}; | |
function factory(data: Omit<D, 'type' | 'path'>): D { | |
const path = toPath(specs, data); | |
return { | |
...data, | |
type, | |
path | |
} as D; | |
} | |
factory.fromPath = fromPath; | |
factory.specs = specs; | |
factory.type = type; | |
return factory; | |
} | |
const notFoundType = 'notFound' as 'notFound'; | |
export function notFound(path: string) { | |
return { | |
type: notFoundType, | |
path | |
}; | |
} | |
notFound.type = notFoundType; | |
notFound.fromPath = (path: string) => notFound(path); | |
export function createRouter< | |
T extends Array<Route<any>>, | |
R extends | |
T extends [] ? never : | |
T extends [Route<infer T1>] ? T1 : | |
T extends [Route<infer T1>, Route<infer T2>] ? T1 | T2 : | |
T extends [Route<infer T1>, Route<infer T2>, Route<infer T3>] ? T1 | T2 | T3 : | |
T extends [Route<infer T1>, Route<infer T2>, Route<infer T3>, Route<infer T4>] ? T1 | T2 | T3 | T4 : | |
T extends [Route<infer T1>, Route<infer T2>, Route<infer T3>, Route<infer T4>, Route<infer T5>] ? T1 | T2 | T3 | T4 | T5 : | |
T extends [Route<infer T1>, Route<infer T2>, Route<infer T3>, Route<infer T4>, Route<infer T5>, Route<infer T6>] ? T1 | T2 | T3 | T4 | T5 | T6 : | |
never | |
>( | |
...routes: T | |
) { | |
function fromPath(path: string): R | ReturnType<typeof notFound> { | |
for(let route of routes) { | |
const data = route.fromPath(path); | |
if(data) { | |
return data; | |
} | |
} | |
return notFound.fromPath(path); | |
} | |
return { | |
fromPath: fromPath | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment