Skip to content

Instantly share code, notes, and snippets.

@bmingles
Created May 21, 2020 01:25
Show Gist options
  • Save bmingles/ebc77ba8f1eb5bb48b8cdd9f99103413 to your computer and use it in GitHub Desktop.
Save bmingles/ebc77ba8f1eb5bb48b8cdd9f99103413 to your computer and use it in GitHub Desktop.
Strong typed routing
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