Created
April 22, 2024 11:02
-
-
Save renatoaraujoc/762095ef8ce886fbed3736435004f37a to your computer and use it in GitHub Desktop.
Type-safe routing
This file contains hidden or 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
import { | |
type AfterViewInit, | |
Directive, | |
inject, | |
Injectable, | |
Input | |
} from '@angular/core'; | |
import { type NavigationExtras, Router, RouterLink } from '@angular/router'; | |
import type { Prettify } from '@rcambiental/global/typescript'; | |
import type { z } from 'zod'; | |
/** | |
* Accepted types for a route command. | |
* - It can be a static string that represents part of the route path like 'home' or 'about-us'. | |
* - It can be a static number that represents part of the route path like 1 or 2. | |
* - It can be a dynamic string path param, its represented by prefixing with ':' and the param name, like ':id'. | |
* - It can be a dynamic number path param, its represented by prefixing with ':', followed | |
* by the param name and suffixed with '.n', like ':id.n'. | |
*/ | |
type RouteCommandAcceptedType = string | number | `:${string}` | `:${string}.n`; | |
/** | |
* A route command is a tuple that has at least one element, example: | |
* - ['home'] | |
* - ['cart', ':cardId.n'] | |
* - ['cart', ':cartId.n', 'product', ':productId.n'] | |
*/ | |
type RouteCommand = [RouteCommandAcceptedType, ...RouteCommandAcceptedType[]]; | |
/** | |
* Helper type used by ExtractDynamicUrlParams to intersect the union of | |
* Records into a single Record. | |
*/ | |
type Intersect<T> = (T extends any ? (x: T) => 0 : never) extends ( | |
x: infer R | |
) => 0 | |
? R | |
: never; | |
/** | |
* Given a RouteCommand, extracts the dynamic url path params from it and | |
* return a single Record with all the dynamic params with their respective types. | |
*/ | |
type ExtractDynamicUrlParams<T extends RouteCommand> = Prettify< | |
Intersect< | |
{ | |
[K in keyof T]: T[K] extends `:${infer TParam}.n` | |
? Record<TParam, number> | |
: T[K] extends `:${infer TParam2}` | |
? Record<TParam2, string> | |
: never; | |
}[number] | |
> | |
>; | |
/** | |
* Base type for a route. | |
*/ | |
export type Route = { | |
url: RouteCommand; | |
queryParams?: z.ZodObject<any>; | |
}; | |
type ResolveRouteConfig<T extends Route> = (keyof ExtractDynamicUrlParams< | |
T['url'] | |
> extends never | |
? {} | |
: { urlParams: ExtractDynamicUrlParams<T['url']> }) & | |
(T extends { | |
queryParams: z.ZodObject<any>; | |
} | |
? { | |
queryParams: z.infer<T['queryParams']>; | |
} | |
: {}); | |
export type ResolveRouteReturnFnImpl< | |
TRoute extends Route, | |
TRouteConfig = ResolveRouteConfig<TRoute> | |
> = keyof TRouteConfig extends never | |
? () => { | |
url: string[]; | |
} | |
: TRouteConfig extends { urlParams: any; queryParams: any } | |
? (params: TRouteConfig) => { | |
url: string[]; | |
queryParams: z.infer<NonNullable<TRoute['queryParams']>>; | |
} | |
: TRouteConfig extends { urlParams: any } | |
? (params: TRouteConfig) => { | |
url: string[]; | |
} | |
: TRouteConfig extends { queryParams: any } | |
? (params: TRouteConfig) => { | |
url: string[]; | |
queryParams: z.infer<NonNullable<TRoute['queryParams']>>; | |
} | |
: never; | |
const route = <const TRouteCommand extends Route>( | |
config: TRouteCommand | |
): ResolveRouteReturnFnImpl<TRouteCommand> => | |
((params?: { urlParams?: any; queryParams?: any }) => { | |
// Validate queryParams if present | |
let resolvedQueryParams = {}; | |
if (config.queryParams) { | |
resolvedQueryParams = config.queryParams.parse( | |
(params as any).queryParams ?? {} | |
); | |
} | |
// Construct the url | |
const url = config.url.map((part) => { | |
if (typeof part === 'string') { | |
if (part.startsWith(':')) { | |
const cleanedPart = part.slice(1).replace('.n', ''); | |
if (!(params as any).urlParams[cleanedPart]) { | |
throw new Error( | |
`Missing parameter ${cleanedPart} in urlParams!` | |
); | |
} | |
return (params as any).urlParams[cleanedPart]; | |
} | |
return part; | |
} | |
return part; | |
}) as string[]; | |
return { | |
url, | |
...(config?.queryParams ? { queryParams: resolvedQueryParams } : {}) | |
} as const; | |
}) as ResolveRouteReturnFnImpl<TRouteCommand>; | |
const appRoutes = { | |
home: route({ | |
url: ['/'] | |
}), | |
theCompany: route({ | |
url: ['/a-empresa'] | |
}), | |
contactUs: route({ | |
url: ['/fale-conosco'] | |
}), | |
requestAQuote: route({ | |
url: ['/solicite-um-orcamento'] | |
}), | |
federalLegislation: route({ | |
url: ['/legislacao-ambiental-federal'] | |
}), | |
statesLegislation: route({ | |
url: ['/legislacao-ambiental-estadual'] | |
}), | |
termsOfUse: route({ | |
url: ['/termos-de-uso'] | |
}) | |
} as const; | |
type AppRoutesDefinitions = typeof appRoutes; | |
export const injectAppRoutes = () => inject(AppRoutesService); | |
@Injectable({ | |
providedIn: 'root' | |
}) | |
export class AppRoutesService { | |
public routes = appRoutes; | |
constructor(private router: Router) {} | |
withRoute = <TRoute extends keyof AppRoutesDefinitions>( | |
route: TRoute, | |
extras?: Omit<NavigationExtras, 'queryParams'> | |
) => ({ | |
go: ( | |
...params: Parameters<AppRoutesDefinitions[TRoute]> | |
): Promise<boolean> => { | |
const resolvedRoute = this.routes[route]( | |
// @ts-ignore | |
params?.[0] | |
); | |
return this.router.navigate(resolvedRoute.url, { | |
...extras, | |
...(resolvedRoute?.['queryParams'] | |
? { | |
queryParams: resolvedRoute['queryParams'] | |
} | |
: {}) | |
}); | |
} | |
}); | |
} | |
type GetRouteConfig<TRouteKey extends keyof AppRoutesDefinitions> = Parameters< | |
AppRoutesDefinitions[TRouteKey] | |
>[0]; | |
@Directive({ | |
standalone: true, | |
// eslint-disable-next-line @angular-eslint/directive-selector | |
selector: '[feClientLink]', | |
hostDirectives: [RouterLink] | |
}) | |
export class FeClientLinkDirective< | |
const TRouteKey extends keyof AppRoutesDefinitions | |
> implements AfterViewInit | |
{ | |
private readonly _routerLinkDirective = inject(RouterLink); | |
private _routeKey: TRouteKey; | |
private _routeNeedsConfig = false; | |
private _routeConfigInputWasSet = false; | |
@Input() set feClientLink(route: TRouteKey) { | |
this._routeKey = route; | |
if (!appRoutes[route]) { | |
throw new Error(`There's no defined route with name '${route}'!`); | |
} | |
try { | |
// @ts-ignore | |
const result = appRoutes[route](); | |
this._routerLinkDirective.routerLink = result.url; | |
this._routerLinkDirective['updateHref'](); | |
} catch (e) { | |
this._routeNeedsConfig = true; | |
} | |
} | |
@Input() set linkConfig(value: GetRouteConfig<TRouteKey>) { | |
this._routeConfigInputWasSet = true; | |
const result = appRoutes[this._routeKey]( | |
// @ts-ignore | |
value | |
); | |
this._routerLinkDirective.routerLink = result.url; | |
if (result?.['queryParams']) { | |
this._routerLinkDirective.queryParams = result['queryParams']; | |
} | |
this._routerLinkDirective['updateHref'](); | |
} | |
ngAfterViewInit() { | |
if (this._routeNeedsConfig && !this._routeConfigInputWasSet) { | |
throw new Error( | |
`FeClientLinkDirective route[${this._routeKey}] has parameters to be configured!` | |
); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment