Making URLs Type-Safe in TypeScript
I kept hitting this problem: I'd write a URL like "/users/:userId/posts/:postId" and TypeScript would just see it as string. No autocomplete. No type safety. Just a plain string.
But what if TypeScript could look at that string and figure out that userId and postId are parameters? Then it could enforce that you pass them in, give you autocomplete, and catch typos before you even run the code.
Turns out, you can do exactly that with template literal types.
In this post I'm going to walk through:
- How template literal types work
- Extracting types from const strings step by step
- Making URLs fully type-safe at compile time
- Where else you can use these patterns
The problem
Here's what I wanted:
const url = "/users/:userId/posts/:postId?page=&limit=10"
// I want TypeScript to extract:
// - Path params: { userId: string; postId: string }
// - Query params: { page?: string; limit?: string }When you call a function with that URL, TypeScript should require you to pass those exact parameters. No more, no less.
Step 1: Understanding template literal types
First, let's understand the tool we're working with. Template literal types let you pattern-match against strings at the type level.
Here's the simplest example:
type Greeting<Name extends string> = `Hello, ${Name}!`
type Test = Greeting<"World">
// Result: "Hello, World!"That's just string interpolation at the type level. But it gets more interesting when you use infer to extract parts of a string:
type ExtractName<S extends string> =
S extends `Hello, ${infer Name}!`
? Name
: never
type Test = ExtractName<"Hello, World!">
// Result: "World"This is the key insight: if a string matches a pattern, TypeScript can pull out the parts you care about.
You can also use recursion to process complex patterns:
type Split<S extends string> =
S extends `${infer First}/${infer Rest}`
? [First, ...Split<Rest>]
: [S]
type Test = Split<"users/123/posts/456">
// Result: ["users", "123", "posts", "456"]Now let's apply this to URLs.
Step 2: Extracting path parameters
Path parameters are the :paramName parts in URLs. Let's extract them one step at a time.
Step 1: Check if a segment is a parameter
type ParamOnly<Segment extends string> =
Segment extends `:${infer Param}`
? { [Key in Param]: string }
: {}
type Test1 = ParamOnly<":userId">
// Result: { userId: string }
type Test2 = ParamOnly<"users">
// Result: {}If a segment starts with :, we extract the name and create an object type. Otherwise, empty object.
Step 2: Process all segments in the path
Now we need to split the URL on / and check each segment:
type PathSegment<Path extends string> =
Path extends `${infer SegmentA}/${infer SegmentB}`
? ParamOnly<SegmentA> & PathSegment<SegmentB>
: ParamOnly<Path>
type Test = PathSegment<"users/:userId/posts/:postId">
// Result: { userId: string; postId: string }This recursively splits on /, runs ParamOnly on each part, and merges the results with &.
Here's how it processes "users/:userId/posts/:postId":
- Split:
"users"and":userId/posts/:postId" ParamOnly<"users">={}- Recurse on
":userId/posts/:postId" - Split:
":userId"and"posts/:postId" ParamOnly<":userId">={ userId: string }- Keep going until we hit
":postId" - Merge:
{} & { userId: string } & {} & { postId: string }
Step 3: Handle query strings
URLs can have query parameters after ?. We need to stop processing path params when we hit that:
type PathSegments<Path extends string> =
Path extends `${infer SegmentA}?${infer _SegmentB}`
? PathSegment<SegmentA>
: PathSegment<Path>
type Test = PathSegments<"/users/:userId?page=1">
// Result: { userId: string }This splits on ? and only processes the first part.
Putting it together
type RouteParams<Path extends string> = PathSegments<Path>
type Params = RouteParams<"/users/:userId/posts/:postId">
// Result: { userId: string; postId: string }Now TypeScript knows exactly which parameters your URL needs.
Step 3: Extracting query parameters
Query parameters work differently. They come after ?, they're always optional, and they can appear multiple times (which is why the type is string | string[]).
Step 1: Check if a string is a query param
type IsSearchParam<SearchParam extends string> =
SearchParam extends `${infer ParamName}=${infer _ParamValue}`
? { [Key in ParamName]?: string | string[] }
: {}
type Test1 = IsSearchParam<"page=1">
// Result: { page?: string | string[] }
type Test2 = IsSearchParam<"limit=">
// Result: { limit?: string | string[] }We look for name=value and make it optional. The value part doesn't matter for types — we just need the name.
Step 2: Process multiple query params
Query params are separated by &. Same recursion pattern as before:
type SearchSegment<Path extends string> =
Path extends `${infer FirstSearchParam}&${infer OtherSearchParams}`
? IsSearchParam<FirstSearchParam> & SearchSegment<OtherSearchParams>
: IsSearchParam<Path>
type Test = SearchSegment<"page=1&limit=10&sort=">
// Result: { page?: string | string[]; limit?: string | string[]; sort?: string | string[] }Step 3: Extract from the full URL
type SearchSegments<Path extends string> =
Path extends `${infer _Url}?${infer SearchParams}`
? SearchSegment<SearchParams>
: never
type Test = SearchSegments<"/users/:id?page=&limit=10">
// Result: { page?: string | string[]; limit?: string | string[] }Now we have both path params and query params as typed objects.
Step 4: Type-level string replacement
Here's where it gets interesting. TypeScript can actually replace strings at the type level.
When you call a function with { id: "123" }, the return type can be "/users/123" instead of just string.
Step 1: Replace one occurrence
type ReplaceAllOnce<
S extends string,
K extends string,
V extends string,
> = S extends `${infer H}:${K}${infer T}`
? `${H}${V}${T}`
: S
type Test = ReplaceAllOnce<"/users/:id/posts", "id", "123">
// Result: "/users/123/posts"This finds :id in the string and swaps it with 123.
Step 2: Replace all occurrences
What if :id appears multiple times? We need recursion:
type ReplaceAll<
S extends string,
K extends string,
V extends string,
D extends number = 6, // Depth limit to prevent compiler hangs
> = D extends 0
? S
: S extends `${string}:${K}${string}`
? ReplaceAll<ReplaceAllOnce<S, K, V>, K, V, D - 1>
: S
type Test = ReplaceAll<"/users/:id/posts/:id", "id", "123">
// Result: "/users/123/posts/123"This keeps replacing until no more matches exist, or we hit the depth limit.
Step 3: Replace from an object
We don't pass individual parameters — we pass an object like { userId: "123", postId: "456" }. Let's handle that:
type Entries<R extends Record<string, string>> = {
[K in keyof R]: [K & string, R[K] & string]
}[keyof R]
type ReplaceEntries<S extends string, E> =
E extends [infer K extends string, infer V extends string]
? ReplaceEntries<ReplaceAll<S, K, V>, Exclude<E, [K, V]>>
: S
type Test = ReplaceEntries<
"/users/:userId/posts/:postId",
["userId", "123"] | ["postId", "456"]
>
// Result: "/users/123/posts/456"This converts the object into a union of [key, value] tuples, then processes each one.
Putting it together
type FinalUrl<
TUrl extends string,
TReplace extends Record<string, string>,
> = ReplaceInPath<TUrl, TReplace>
type Test = FinalUrl<
"/users/:userId/posts/:postId",
{ userId: "123"; postId: "456" }
>
// Result: "/users/123/posts/456"Now your function can return the literal URL string, not just string.
Step 5: Using it in a real function
Now let's see how these types work in practice:
type ReplaceProp<TPath extends string> =
RouteParams<TPath> extends {}
? keyof RouteParams<TPath> extends never
? {} // No path params
: { replace: RouteParams<TPath> } // Has path params → required
: {}
function buildUrl<TUrl extends string>(
args: {
url: TUrl;
filters?: OptionalRouteParams<TUrl> & Record<string, string | string[]>;
} & ReplaceProp<TUrl>
): FinalUrl<TUrl, any> {
// Runtime implementation here
return "" as any
}Now watch what happens:
// With path params
const url1 = buildUrl({
url: "/users/:userId?page=",
replace: { userId: "123" }, // ← Required! TypeScript knows this
filters: { page: "1" } // ← Optional
})
// Type: "/users/123"
// Without path params
const url2 = buildUrl({
url: "/products?limit=10",
filters: { limit: "20" } // ← Optional, no replace needed
})
// Type: "/products"
// TypeScript catches mistakes
const url3 = buildUrl({
url: "/users/:userId",
// Error: Property 'replace' is missing
})Everything is validated at compile time. No runtime overhead for the type checking.
Where else you can use this
Once you understand this pattern, you'll start seeing it everywhere. Any time you have a string that encodes structure, you can extract types from it.
SQL query builders:
type Params = ExtractParams<"SELECT * FROM users WHERE id = :userId AND role = :role">
// Result: { userId: string; role: string }Template strings:
type Vars = ExtractVars<"Hello {{name}}, welcome to {{place}}!">
// Result: { name: string; place: string }Route patterns:
type RouteMatch = MatchRoute<"/blog/:slug", "/blog/hello-world">
// Result: { slug: "hello-world" }The same techniques work for all of them.
The tradeoffs
What you get:
- Zero runtime overhead (it's all compile-time)
- Full autocomplete in your IDE
- Type errors before you run the code
- No build steps or code generation
What to watch out for:
- Complex types can slow down the TypeScript compiler
- Recursion depth limits (typically 5-10 levels)
- Error messages can get cryptic with deeply nested types
- Only works with string literals, not the generic
stringtype
In my experience, the benefits far outweigh the costs. The compile time hit is usually negligible unless you're doing something extreme.
Taking it further
I took these concepts and built a full URL generator library that handles all the edge cases: query arrays, optional segments, encoding, and more.
If you want to see how all these pieces fit together in a production-ready implementation, check out typesafe-url-generator on GitHub.
Happy coding! 👋