Template Literal Types for /users/:id Routes
Turn a route pattern into a type that finds the parameter names and checks the paths you pass. After this, a typo in a route or a missing parameter is a compile error.
Skip ahead: If you only want the finished ParamNames type, skip to "Step 2: read the names out of the pattern", then continue.
You have route patterns like "/users/:id/posts/:postId" and a function that fills them in. Nothing stops a caller from forgetting postId or writing postID. Template literal types can read the parameter names out of the pattern, so the compiler asks for exactly the values the pattern contains. This page builds that in three steps, with a function that really runs.
Step 1: the function without types
function buildPath(pattern: string, params: Record<string, string | number>): string {
return pattern.replace(/:([A-Za-z]+)/g, (_match, name: string) =>
encodeURIComponent(String(params[name])),
);
}
console.log(buildPath("/users/:id/posts/:postId", { id: 7, postID: 3 }));
This compiles, and the typo postID goes through. At run time the missing value becomes the text undefined inside the URL. A Record<string, ...> accepts every key, so there is nothing for the compiler to check.
Step 2: read the names out of the pattern
type ParamNames<P extends string> =
P extends `${string}:${infer Name}/${infer Rest}`
? Name | ParamNames<`/${Rest}`>
: P extends `${string}:${infer Name}`
? Name
: never;
const a: ParamNames<"/users/:id"> = "nope";
const b: ParamNames<"/users/:id/posts/:postId"> = "nope";
const c: ParamNames<"/health"> = "nope";
names.ts(8,7): error TS2322: Type '"nope"' is not assignable to type '"id"'.
names.ts(9,7): error TS2322: Type '"nope"' is not assignable to type '"id" | "postId"'.
names.ts(10,7): error TS2322: Type '"nope"' is not assignable to type 'never'.
The type has two cases. If a colon is followed by a name and then a slash, take the name and keep going with the rest of the string, putting the slash back so the next colon is found. If the colon is at the end, take the name and stop. If there is no colon, nothing is left, and the result is never.
Read the three errors as answers. "/users/:id" gives id. The longer pattern gives "id" | "postId", both names in a union. A route with no parameters gives never, which is why the third message says never.
Step 3: ask for exactly those keys
type ParamNames<P extends string> =
P extends `${string}:${infer Name}/${infer Rest}`
? Name | ParamNames<`/${Rest}`>
: P extends `${string}:${infer Name}`
? Name
: never;
type Params<P extends string> = { [K in ParamNames<P>]: string | number };
function buildPath<P extends string>(pattern: P, params: Params<P>): string {
return pattern.replace(/:([A-Za-z]+)/g, (_match, name: string) =>
encodeURIComponent(String(params[name as keyof Params<P>])),
);
}
console.log(buildPath("/users/:id/posts/:postId", { id: 7, postId: 3 }));
console.log(buildPath("/search/:term", { term: "type scripts" }));
console.log(buildPath("/health", {}));
/users/7/posts/3
/search/type%20scripts
/health
P is the pattern as a literal, so the compiler knows which names it contains, and Params<P> is an object type with those names as required keys. The as keyof Params<P> cast is the one place the types and the run-time string work meet: the regular expression finds the names at run time and the compiler cannot connect the two. Keep it in this one function.
Now the mistakes become compile errors.
type ParamNames<P extends string> =
P extends `${string}:${infer Name}/${infer Rest}`
? Name | ParamNames<`/${Rest}`>
: P extends `${string}:${infer Name}`
? Name
: never;
type Params<P extends string> = { [K in ParamNames<P>]: string | number };
declare function buildPath<P extends string>(pattern: P, params: Params<P>): string;
buildPath("/users/:id/posts/:postId", { id: 7 });
buildPath("/users/:id/posts/:postId", { id: 7, postID: 3 });
build_errors.ts(12,39): error TS2741: Property 'postId' is missing in type '{ id: number; }' but required in type 'Params<"/users/:id/posts/:postId">'.
build_errors.ts(13,48): error TS2561: Object literal may only specify known properties, but 'postID' does not exist in type 'Params<"/users/:id/posts/:postId">'. Did you mean to write 'postId'?
A missing parameter and a typo each fail with a message that names the key. One gap is left: for a route with no parameters, Params is an empty object type, and an empty object type accepts any object literal, so buildPath("/health", { id: 1 }) still compiles. If that matters to you, return Record<string, never> from Params when ParamNames<P> is never.
⚠ Warning: The pattern must be a literal for this to work. If it comes from a variable typed as string, ParamNames<string> is never and the check disappears quietly. Keep route patterns in a const object, as in the lesson on as const, and pass them from there.
Common mistakes
- Expecting the type to validate what comes in at run time. A request that arrives at
/users/abcstill has the textabc. The type only checks the calls you write. - Parsing patterns with optional parts or wildcards in the type. Each rule you add makes the type harder to read. Stop at
:name. - Using
stringfor the values when a parameter is always a number. Brand it, or take a second map of types per route.
Related course: TypeScript Advanced.
About
Turn a route pattern into a type that finds the parameter names and checks the paths you pass. After this, a typo in a route or a missing parameter is a compile error.
- Responsible
- Christopher R
- Last Update
- 10/19/2026
- Reading Time
- 5 min
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.