Skip to content

Instantly share code, notes, and snippets.

@renatoaraujoc
Created April 22, 2024 11:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save renatoaraujoc/762095ef8ce886fbed3736435004f37a to your computer and use it in GitHub Desktop.
Save renatoaraujoc/762095ef8ce886fbed3736435004f37a to your computer and use it in GitHub Desktop.
Type-safe routing
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