6 min read

TypeScript’s Template Literal Types: Crafting Dynamic, Type‑Safe APIs

Learn how template literal types turn string‑based API routes into compile‑time guarantees, enabling a fully typed request layer.
TypeScript’s Template Literal Types: Crafting Dynamic, Type‑Safe APIs

Introduction

When you call a REST or GraphQL endpoint, the URL is usually a plain string. A typo in that string or a missing query parameter becomes a runtime error that can slip through testing. TypeScript’s template literal types (TLTs) give us a way to describe those strings at the type level, turning what was once a runtime concern into a compile‑time guarantee.

In this article we’ll explore:

  • The fundamentals of template literal types.
  • How to generate unions of valid route strings automatically.
  • Building a type‑safe request helper that infers path parameters and query strings.
  • Extending the pattern to nested resources and versioned APIs.

All examples are written in plain TypeScript (no external libraries) so you can copy them into a project instantly.


1. Template Literal Types in a Nutshell

A template literal type looks just like a JavaScript template literal, but it lives in the type system:

type Greeting = `Hello, ${string}!`;

Greeting is now a string type that must start with "Hello, " and end with "!". Anything else is rejected:

declare const g1: Greeting;
g1 = "Hello, world!";   // ✅
g1 = "Hi, world!";      // ❌ Type '"Hi, world!"' is not assignable to type Greeting

The power comes from interpolating other types (string, numeric literals, unions, etc.) inside the back‑ticks. When you combine a union with a template literal, TypeScript distributes the union automatically:

type Method = "GET" | "POST";
type HttpMethod = `${Method}`;

HttpMethod resolves to "GET" | "POST".


2. From Static Paths to Dynamic Unions

Imagine an API with the following endpoints:

Method Path
GET /users
GET /users/:id
POST /users
PATCH /users/:id
DELETE /users/:id
GET /projects/:projectId/tasks/:taskId

Instead of hand‑crafting a union of strings, we can derive it from a data structure:

type Resource = {
  users: {
    id: number;
  };
  projects: {
    projectId: number;
    tasks: {
      taskId: number;
    };
  };
};

type PathSegments<R> = R extends object
  ? {
      [K in keyof R]: K extends string
        ? R[K] extends object
          ? `${K}/${PathSegments<R[K]>}`
          : `${K}`
        : never;
    }[keyof R]
  : never;

type ApiPath = PathSegments<Resource>;

ApiPath evaluates to the union:

"users" |
"users/:id" |
"projects/:projectId/tasks/:taskId"

The trick is the recursive conditional type PathSegments. While we are using a conditional type internally, the focus of the article remains on template literal types; the conditional is merely a helper to walk the object shape.


3. Extracting Parameter Names

Once we have a union of route patterns, we often need the parameter names (:id, :projectId, …) to enforce that callers provide the right values. A small utility type does the extraction:

type ExtractParams<S extends string> =
  S extends `${infer _Prefix}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`:${Rest}`>
    : S extends `${infer _Prefix}:${infer Param}`
      ? Param
      : never;

Usage:

type UserParams = ExtractParams<"users/:id">;               // "id"
type TaskParams = ExtractParams<"projects/:projectId/tasks/:taskId">;
// "projectId" | "taskId"

ExtractParams walks the string, pulling out each :${name} token. It works for any depth because it recurses on the remainder after the first colon.


4. A Type‑Safe Request Helper

Now we can combine the pieces into a tiny HTTP client that guarantees:

  • The path matches one of the known routes.
  • All required parameters are supplied.
  • The query string keys are limited to a whitelist (optional, shown later).
type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";

type RequestOptions<
  P extends string,
  M extends HttpMethod = "GET",
  Q extends Record<string, any> = {}
> = {
  method?: M;
  params?: Record<ExtractParams<P>, string | number>;
  query?: Q;
  body?: any;
};

async function request<P extends ApiPath>(
  path: P,
  opts: RequestOptions<P> = {}
): Promise<any> {
  const { method = "GET", params = {}, query = {}, body } = opts;

  // 1️⃣ Replace :param placeholders
  const url = (Object.entries(params) as [keyof typeof params, string | number][])
    .reduce((acc, [key, value]) => acc.replace(`:${key}`, encodeURIComponent(String(value))), `/${path}`);

  // 2️⃣ Append query string if present
  const queryString = new URLSearchParams(query as any).toString();
  const finalUrl = queryString ? `${url}?${queryString}` : url;

  const response = await fetch(finalUrl, {
    method,
    headers: { "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }
  return response.json();
}

How the Types Protect You

// ✅ Correct usage
await request("users/:id", {
  method: "GET",
  params: { id: 42 },
});

// ❌ Missing required param
await request("users/:id", {
  method: "GET",
}); // Error: Property 'id' is missing in type '{}' ...

// ❌ Unknown route
await request("unknown/path"); // Error: Type '"unknown/path"' is not assignable to type ApiPath

The compiler now forces you to provide every placeholder defined in the path string, and it prevents you from calling an endpoint that does not exist in the API contract.


5. Typed Query Strings

Often an endpoint accepts a limited set of query parameters. We can model that with a mapped type that ties a query shape to a specific route:

type QueryMap = {
  "users": { page?: number; limit?: number };
  "users/:id": {};                     // No query params
  "projects/:projectId/tasks/:taskId": { expand?: "comments" | "assignee" };
};

type QueryFor<P extends ApiPath> = P extends keyof QueryMap ? QueryMap[P] : {};

type RequestOptionsWithQuery<P extends ApiPath, M extends HttpMethod = "GET"> =
  RequestOptions<P, M, QueryFor<P>>;

async function requestQ<P extends ApiPath>(
  path: P,
  opts: RequestOptionsWithQuery<P> = {}
) {
  return request(path, opts);
}

Now the query object is constrained:

// ✅ Allowed
await requestQ("projects/:projectId/tasks/:taskId", {
  params: { projectId: 7, taskId: 3 },
  query: { expand: "comments" },
});

// ❌ Invalid query key
await requestQ("projects/:projectId/tasks/:taskId", {
  params: { projectId: 7, taskId: 3 },
  query: { foo: "bar" }, // Error: Object literal may only specify known properties
});

6. Versioned APIs and Nested Resources

Real‑world services evolve. A common pattern is to prefix routes with a version, e.g. /v1/users. We can extend our type generation to include a version segment without duplicating the whole route map.

type Version = "v1" | "v2";

type VersionedPath = `${Version}/${ApiPath}`;

VersionedPath now represents:

"v1/users" |
"v1/users/:id" |
"v1/projects/:projectId/tasks/:taskId" |
"v2/users" |
...

The same request helper works unchanged because the generic P extends ApiPath can be widened:

async function requestV<P extends VersionedPath>(path: P, opts: RequestOptions<P> = {}) {
  // Strip the version before looking up params
  const [, ...rest] = path.split("/");
  const basePath = rest.join("/") as ApiPath;
  return request(basePath, opts);
}

Now you get compile‑time safety for both the version token and the underlying route.


7. Limitations & Gotchas

Issue Explanation Mitigation
Runtime vs. compile‑time TLTs only exist during type checking; they don’t enforce values at runtime. Keep the small runtime helper (request) that replaces placeholders and validates required params.
Complex regex‑like patterns TLTs cannot express arbitrary regex constraints (e.g., numeric only). Use branded types or runtime validation for those edge cases.
Performance of large unions Very large route maps can slow down the TypeScript compiler. Split the API into logical modules and import only the needed unions.
Circular type references Recursive PathSegments can hit the compiler’s recursion limit for deeply nested objects. Limit nesting depth or manually flatten the most complex branches.

Understanding these boundaries helps you decide when TLTs are the right tool versus when a runtime schema validator (like Zod) is more appropriate.


8. Best Practices

  1. Source‑of‑truth object – Keep a single object that describes resources and their parameters. Derive all unions from it to avoid duplication.
  2. Prefer literal unions over string – The more specific the type, the better the IDE autocomplete and error messages.
  3. Separate query shape – Use a dedicated QueryMap so that query validation stays independent of path parameters.
  4. Export only the public types – Hide internal helpers (PathSegments, ExtractParams) behind a barrel file to keep the public API clean.
  5. Write a small test suite – Even though the compiler catches many mistakes, a runtime test that hits each endpoint ensures the URL construction logic works as expected.

9. Putting It All Together

Below is a minimal, self‑contained example you can paste into a *.ts file and run with ts-node:

// 1️⃣ Define the resource shape
type Resource = {
  users: { id: number };
  projects: {
    projectId: number;
    tasks: { taskId: number };
  };
};

// 2️⃣ Generate path union
type PathSegments<R> = R extends object
  ? {
      [K in keyof R]: K extends string
        ? R[K] extends object
          ? `${K}/${PathSegments<R[K]>}`
          : `${K}`
        : never;
    }[keyof R]
  : never;

type ApiPath = PathSegments<Resource>;

// 3️⃣ Extract param names
type ExtractParams<S extends string> =
  S extends `${infer _}:${infer P}/${infer Rest}`
    ? P | ExtractParams<`:${Rest}`>
    : S extends `${infer _}:${infer P}`
      ? P
      : never;

// 4️⃣ Typed request helper (same as earlier)
type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";

type RequestOptions<P extends string, M extends HttpMethod = "GET", Q extends Record<string, any> = {}> = {
  method?: M;
  params?: Record<ExtractParams<P>, string | number>;
  query?: Q;
  body?: any;
};

async function request<P extends ApiPath>(path: P, opts: RequestOptions<P> = {}): Promise<any> {
  const { method = "GET", params = {}, query = {}, body } = opts;
  const url = (Object.entries(params) as [keyof typeof params, string | number][])
    .reduce((acc, [k, v]) => acc.replace(`:${k}`, encodeURIComponent(String(v))), `/${path}`);
  const qs = new URLSearchParams(query as any).toString();
  const final = qs ? `${url}?${qs}` : url;
  const res = await fetch(final, { method, body: body ? JSON.stringify(body) : undefined });
  return res.json();
}

// 5️⃣ Example calls
(async () => {
  // ✅ Correct
  await request("users/:id", { params: { id: 12 } });

  // ❌ Compile‑time error – missing param
  // await request("users/:id");

  // ✅ With query
  await request("projects/:projectId/tasks/:taskId", {
    params: { projectId: 5, taskId: 9 },
    query: { expand: "comments" } // will be allowed if you add a QueryMap
  });
})();

Running the file will perform real HTTP calls (replace the base URL with your API). The TypeScript compiler will reject any misuse before the code even runs.


Conclusion

Template literal types turn the stringly‑typed world of URLs into a type‑safe domain. By describing routes as literal unions, extracting parameter names, and coupling them with a tiny runtime helper, you get:

  • Zero‑runtime surprises – missing parameters are caught at compile time.
  • Self‑documenting code – IDEs show exact path strings and required keys.
  • Scalable contracts – adding a new endpoint is a matter of updating a single object, and the rest of the type system updates automatically.

Adopt this pattern in any TypeScript codebase that talks to external services, and you’ll spend far less time debugging 404s caused by typos or mismatched parameters. Happy typing!