next-safe-navigation
Version:
Type-safe navigation for NextJS App router
1 lines • 18.7 kB
Source Map (JSON)
{"version":3,"sources":["../src/create-navigation-config.ts","../src/convert-url-search-params-to-object.ts","../src/convert-object-to-url-search-params.ts","../src/make-route-builder.ts"],"sourcesContent":["import {\n useParams as useNextParams,\n useSearchParams as useNextSearchParams,\n} from 'next/navigation';\n\nimport { type z } from 'zod';\n\nimport { convertURLSearchParamsToObject } from './convert-url-search-params-to-object';\nimport { makeRouteBuilder, type RouteBuilder } from './make-route-builder';\nimport type { Prettify } from './types';\n\ntype AnyRouteBuilder =\n | RouteBuilder<string, any, any>\n | RouteBuilder<string, any, never>\n | RouteBuilder<string, never, any>\n | RouteBuilder<string, never, never>;\n\ntype NavigationConfig = Record<string, AnyRouteBuilder>;\n\ntype SafeRootRoute<Path extends string> = () => Path;\n\ntype SafeRouteWithParams<Path extends string, Params extends z.ZodSchema> = {\n (options: z.input<Params>): Path;\n $parseParams: (params: unknown) => z.output<Params>;\n};\n\ntype SafeRouteWithSearch<Path extends string, Search extends z.ZodSchema> = {\n (options?: { search?: z.input<Search> }): Path;\n $parseSearchParams: (searchParams: unknown) => z.output<Search>;\n};\n\ntype SafeRouteWithRequiredSearch<\n Path extends string,\n Search extends z.ZodSchema,\n> = {\n (options: { search: z.input<Search> }): Path;\n $parseSearchParams: (searchParams: unknown) => z.output<Search>;\n};\n\ntype SafeRouteWithParamsAndSearch<\n Path extends string,\n Params extends z.ZodSchema,\n Search extends z.ZodSchema,\n Options = z.input<Params> & { search?: z.input<Search> },\n> = {\n (options: Prettify<Options>): Path;\n $parseParams: (params: unknown) => z.output<Params>;\n $parseSearchParams: (searchParams: unknown) => z.output<Search>;\n};\n\ntype SafeRouteWithParamsAndRequiredSearch<\n Path extends string,\n Params extends z.ZodSchema,\n Search extends z.ZodSchema,\n Options = z.input<Params> & { search: z.input<Search> },\n> = {\n (options: Prettify<Options>): Path;\n $parseParams: (params: unknown) => z.output<Params>;\n $parseSearchParams: (searchParams: unknown) => z.output<Search>;\n};\n\ntype SafeRoute<\n Path extends string,\n Params extends z.ZodSchema,\n Search extends z.ZodSchema,\n> =\n [Params, Search] extends [never, never] ? SafeRootRoute<Path>\n : [Params, Search] extends [z.ZodSchema, never] ?\n SafeRouteWithParams<Path, Params>\n : [Params, Search] extends [never, z.ZodSchema] ?\n undefined extends z.input<Search> ?\n SafeRouteWithSearch<Path, Search>\n : SafeRouteWithRequiredSearch<Path, Search>\n : [Params, Search] extends [z.ZodSchema, z.ZodSchema] ?\n undefined extends z.input<Search> ?\n SafeRouteWithParamsAndSearch<Path, Params, Search>\n : SafeRouteWithParamsAndRequiredSearch<Path, Params, Search>\n : never;\n\ntype RouteWithParams<Config extends NavigationConfig> = {\n [Route in keyof Config & string]: Config[Route] extends (\n RouteBuilder<string, infer Params extends z.ZodSchema, infer _>\n ) ?\n Params extends z.ZodSchema ?\n Route\n : never\n : never;\n}[keyof Config & string];\n\ntype RouteWithSearchParams<Config extends NavigationConfig> = {\n [Route in keyof Config & string]: Config[Route] extends (\n RouteBuilder<\n string,\n infer _ extends z.ZodSchema,\n infer Search extends z.ZodSchema\n >\n ) ?\n Search extends z.ZodSchema ?\n Route\n : never\n : never;\n}[keyof Config & string];\n\ntype SafeNavigation<Config extends NavigationConfig> = {\n [Route in keyof Config]: Config[Route] extends (\n RouteBuilder<\n infer Path extends string,\n infer Params extends z.ZodSchema,\n infer Search extends z.ZodSchema\n >\n ) ?\n SafeRoute<Path, Params, Search>\n : never;\n};\n\ntype ValidatedRouteParams<\n Config extends NavigationConfig,\n Route extends string,\n AcceptableRoute extends string,\n Router = SafeNavigation<Config>,\n> =\n Route extends keyof Pick<Router, AcceptableRoute & keyof Router> ?\n Router[Route] extends (\n | SafeRoute<string, infer Params extends z.ZodSchema, any>\n | SafeRoute<string, infer Params extends z.ZodSchema, never>\n ) ?\n z.output<Params>\n : never\n : never;\n\ntype ValidatedRouteSearchParams<\n Config extends NavigationConfig,\n Route extends string,\n AcceptableRoute extends string,\n Router = SafeNavigation<Config>,\n> =\n Route extends keyof Pick<Router, AcceptableRoute & keyof Router> ?\n Router[Route] extends (\n | SafeRoute<string, any, infer Search extends z.ZodSchema>\n | SafeRoute<string, never, infer Search extends z.ZodSchema>\n ) ?\n z.output<Search>\n : never\n : never;\n\ninterface SafeNavigationConfigImpl<\n Config extends NavigationConfig,\n $SafeRouter extends SafeNavigation<any> = SafeNavigation<Config>,\n $RouteWithParams extends string = RouteWithParams<Config>,\n $RouteWithSearchParams extends string = RouteWithSearchParams<Config>,\n> {\n routes: $SafeRouter;\n useSafeParams: <Route extends keyof $SafeRouter & string>(\n route: Extract<$RouteWithParams, Route>,\n ) => ValidatedRouteParams<Config, Route, $RouteWithParams>;\n useSafeSearchParams: <Route extends keyof $SafeRouter & string>(\n route: Extract<$RouteWithSearchParams, Route>,\n ) => ValidatedRouteSearchParams<Config, Route, $RouteWithSearchParams>;\n}\n\ntype SafeNavigationConfig<Config extends NavigationConfig> =\n SafeNavigationConfigImpl<Config>;\n\nexport function createNavigationConfig<Config extends NavigationConfig>(\n createConfig: (defineRoute: makeRouteBuilder) => Config,\n): SafeNavigationConfig<Config> {\n const navigationConfig = createConfig(makeRouteBuilder);\n\n const schemasStore = new Map<\n keyof Config,\n Partial<Record<'params' | 'search', z.ZodSchema>>\n >();\n\n for (const [route, builder] of Object.entries(navigationConfig)) {\n const schemas = builder.getSchemas();\n\n // @ts-expect-error overwriting runtime implementation\n builder.getSchemas = undefined;\n\n if (schemas.params != null || schemas.search != null) {\n schemasStore.set(route, schemas);\n }\n\n if (schemas.params) {\n const paramsSchema = schemas.params as z.ZodSchema;\n\n (builder as any).$parseParams = (input: unknown) => {\n const validation = paramsSchema.safeParse(input);\n\n if (!validation.success) {\n throw new Error(\n `Invalid route params for route \"${route}\": ${validation.error.message}`,\n );\n }\n\n return validation.data;\n };\n }\n\n if (schemas.search) {\n const searchSchema = schemas.search as z.ZodSchema;\n\n (builder as any).$parseSearchParams = (input: unknown) => {\n const validation = searchSchema.safeParse(input);\n\n if (!validation.success) {\n throw new Error(\n `Invalid search params for route \"${route}\": ${validation.error.message}`,\n );\n }\n\n return validation.data;\n };\n }\n }\n\n function useSafeParams(route: string) {\n const schema = schemasStore.get(route);\n\n if (!schema?.params) {\n throw new Error(`Route \"${route}\" does not have params validation`);\n }\n\n const validation = schema.params.safeParse(useNextParams());\n\n if (!validation.success) {\n throw new Error(\n `Invalid route params for route \"${route}\": ${validation.error.message}`,\n );\n }\n\n return validation.data;\n }\n\n function useSafeSearchParams(route: string) {\n const schema = schemasStore.get(route);\n\n if (!schema?.search) {\n throw new Error(`Route \"${route}\" does not have searchParams validation`);\n }\n\n const validation = schema.search.safeParse(\n convertURLSearchParamsToObject(useNextSearchParams()),\n );\n\n if (!validation.success) {\n throw new Error(\n `Invalid search params for route \"${route}\": ${validation.error.message}`,\n );\n }\n\n return validation.data;\n }\n\n return {\n routes: navigationConfig as unknown as SafeNavigation<Config>,\n useSafeParams,\n useSafeSearchParams,\n };\n}\n","import { type ReadonlyURLSearchParams } from 'next/navigation';\n\nexport function convertURLSearchParamsToObject(\n params: ReadonlyURLSearchParams | null,\n): Record<string, string | string[]> {\n if (!params) {\n return {};\n }\n\n return [...params.entries()].reduce<Record<string, string | string[]>>(\n (acc, [key, value]) => {\n const values = params.getAll(key);\n\n acc[key] = values.length > 1 ? values : value;\n\n return acc;\n },\n {},\n );\n}\n","export function convertObjectToURLSearchParams(\n object: Record<string, string | string[]>,\n): URLSearchParams {\n const urlSearchParams = new URLSearchParams();\n\n for (const [key, value] of Object.entries(object)) {\n if (Array.isArray(value)) {\n for (const entry of value) {\n urlSearchParams.append(key, `${entry}`);\n }\n\n continue;\n }\n\n urlSearchParams.append(key, `${value}`);\n }\n\n return urlSearchParams;\n}\n","import { type Route } from 'next';\n\nimport { type z } from 'zod';\n\nimport { convertObjectToURLSearchParams } from './convert-object-to-url-search-params';\nimport type { ExcludeAny } from './types';\n\ntype PathBlueprint = `/${string}`;\n\ntype Suffix = `?${string}`;\n\n/**\n * When `experimental.typeRoutes` is disabled,\n * `Route` is `string & {}`, therefore `string extends Route` is a truthy condition.\n * If this is the case, we simply use the `Path` value to infer the literal string.\n *\n * If `experimental.typeRoutes` is enabled,\n * `Route` will be a union of string literals, therefore `string extends Route` is a falsy condition.\n * If this is the case, we use `Route<Path>` so that we have auto-complete on the available routes\n * generated by NextJS and validation check against dynamic routes (that are checked by passing the string generic).\n */\ntype SafePath<Path extends string> = string extends Route ? Path : Route<Path>;\n\ntype ExtractPathParams<T extends string> =\n T extends `${infer Rest}[[...${infer Param}]]` ?\n Param | ExtractPathParams<Rest>\n : T extends `${infer Rest}[...${infer Param}]` ?\n Param | ExtractPathParams<Rest>\n : T extends `${string}[${infer Param}]${infer Rest}` ?\n Param | ExtractPathParams<Rest>\n : never;\n\nexport type RouteBuilder<\n Path extends string,\n Params extends z.ZodSchema,\n Search extends z.ZodSchema,\n> =\n [Params, Search] extends [never, never] ?\n { (): Path; getSchemas: () => { params: never; search: never } }\n : [Params, Search] extends [z.ZodSchema, never] ?\n {\n (options: z.input<Params>): Path;\n getSchemas: () => { params: Params; search: never };\n }\n : [Params, Search] extends [never, z.ZodSchema] ?\n undefined extends z.input<Search> ?\n {\n (options?: { search?: z.input<Search> }): Path | `${Path}${Suffix}`;\n getSchemas: () => { params: never; search: Search };\n }\n : {\n (options: { search: z.input<Search> }): `${Path}${Suffix}`;\n getSchemas: () => { params: never; search: Search };\n }\n : [Params, Search] extends [z.ZodSchema, z.ZodSchema] ?\n undefined extends z.input<Search> ?\n {\n (\n options: z.input<Params> & { search?: z.input<Search> },\n ): Path | `${Path}${Suffix}`;\n getSchemas: () => { params: Params; search: Search };\n }\n : {\n (\n options: z.input<Params> & { search: z.input<Search> },\n ): `${Path}${Suffix}`;\n getSchemas: () => { params: Params; search: Search };\n }\n : never;\n\ntype EnsurePathWithNoParams<Path extends string> =\n ExtractPathParams<Path> extends never ? SafePath<Path>\n : `[ERROR]: Missing validation for path params`;\n\n/**\n * Ensures no extra values are passed to params validation\n */\ntype StrictParams<Schema extends z.ZodSchema, Keys extends string> =\n Schema extends z.ZodObject<infer Params> ?\n [keyof Params] extends [Keys] ?\n Schema\n : z.ZodObject<{\n [Key in keyof Params]: Key extends Keys ? Params[Key] : never;\n }>\n : never;\n\ntype RouteBuilderResult<\n Path extends string,\n PathParams extends string,\n Params extends z.ZodObject<any>,\n Search extends z.ZodSchema,\n> =\n [PathParams, Search] extends [string, never] ?\n RouteBuilder<Path, Params, never>\n : [PathParams, Search] extends [never, z.ZodSchema] ?\n RouteBuilder<Path, never, Search>\n : [PathParams, Search] extends [string, z.ZodSchema] ?\n RouteBuilder<Path, Params, Search>\n : never;\n\nconst PATH_PARAM_REGEX = /\\[{1,2}([^[\\]]+)]{1,2}/g;\n\n/**\n * Remove param notation from string to only get the param name when it is a catch-all segment\n *\n * @example\n * ```ts\n * '/shop/[[...slug]]'.replace(PATH_PARAM_REGEX, (match, param) => {\n * // ^? '[[...slug]]'\n * const [sanitizedParam] = REMOVE_PARAM_NOTATION_REGEX.exec(param)\n * // ^? 'slug'\n * })\n * ```\n */\nconst REMOVE_PARAM_NOTATION_REGEX = /[^[.].+[^\\]]/;\n\n// @ts-expect-error overload signature does match the implementation,\n// the compiler complains about EnsurePathWithNoParams, but it is fine\nexport function makeRouteBuilder<Path extends PathBlueprint>(\n path: EnsurePathWithNoParams<Path>,\n): RouteBuilder<Path, never, never>;\n\nexport function makeRouteBuilder<\n Path extends PathBlueprint,\n Params extends z.ZodObject<{\n [K in ExtractPathParams<Path>]: z.ZodSchema;\n }>,\n Search extends z.ZodSchema = never,\n>(\n path: SafePath<Path>,\n schemas: ExtractPathParams<Path> extends never ?\n { search: Search | z.ZodOptional<z.ZodSchema> }\n : {\n params: StrictParams<Params, ExtractPathParams<Path>>;\n search?: Search | z.ZodOptional<z.ZodSchema>;\n },\n): RouteBuilderResult<\n Path,\n ExtractPathParams<Path>,\n ExcludeAny<Params>,\n ExcludeAny<Search>\n>;\n\nexport function makeRouteBuilder(\n path: PathBlueprint,\n schemas?: { params?: z.ZodSchema; search?: z.ZodSchema },\n): any {\n if (!path.startsWith('/')) {\n path = `/${path}`;\n }\n\n const hasParamsInPath = PATH_PARAM_REGEX.test(path);\n const isMissingParamsValidation = hasParamsInPath && !schemas?.params;\n\n if (isMissingParamsValidation) {\n throw new Error(`Validation missing for path params: \"${path}\"`);\n }\n\n const routeBuilder: RouteBuilder<string, any, any> = (options) => {\n const { search = {}, ...params } = options ?? {};\n\n const basePath = path.replace(PATH_PARAM_REGEX, (match, param: string) => {\n const sanitizedParam = REMOVE_PARAM_NOTATION_REGEX.exec(param)?.[0];\n\n const value = params[sanitizedParam ?? param];\n\n if (Array.isArray(value)) {\n return value.join('/');\n }\n\n return value ?? match;\n });\n\n const urlSearchParams = convertObjectToURLSearchParams(search);\n\n if (!urlSearchParams.entries().next().done) {\n return [basePath, urlSearchParams.toString()].join('?');\n }\n\n return basePath;\n };\n\n routeBuilder.getSchemas = () => ({\n params: schemas?.params,\n search: schemas?.search,\n });\n\n return routeBuilder;\n}\n\nexport type makeRouteBuilder = typeof makeRouteBuilder;\n"],"mappings":"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","names":["useNextParams","useNextSearchParams","convertURLSearchParamsToObject","params","acc","key","value","values","convertObjectToURLSearchParams","object","urlSearchParams","key","value","entry","PATH_PARAM_REGEX","REMOVE_PARAM_NOTATION_REGEX","makeRouteBuilder","path","schemas","routeBuilder","options","search","params","basePath","match","param","sanitizedParam","value","urlSearchParams","convertObjectToURLSearchParams","createNavigationConfig","createConfig","navigationConfig","makeRouteBuilder","schemasStore","route","builder","schemas","paramsSchema","input","validation","searchSchema","useSafeParams","schema","useNextParams","useSafeSearchParams","convertURLSearchParamsToObject","useNextSearchParams"]}