Skip to content
← Writing
TypeScriptTemplate Literal TypesType SafetyAdvanced Types

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":

  1. Split: "users" and ":userId/posts/:postId"
  2. ParamOnly<"users"> = {}
  3. Recurse on ":userId/posts/:postId"
  4. Split: ":userId" and "posts/:postId"
  5. ParamOnly<":userId"> = { userId: string }
  6. Keep going until we hit ":postId"
  7. 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 string type

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! 👋

Ship it, learn it, repeat.