Learn

/

Template Literals

Template Literals

6 patterns

Template literal types, string manipulation types, and pattern matching on string shapes. You'll hit this when you want the compiler to enforce that a string follows a specific format like `on${string}`.

Avoid
type EventHandler = {
  on: (event: string, cb: () => void) => void;
};

const emitter: EventHandler = getEmitter();
emitter.on("clck", handleClick);
// Typo not caught, handler never fires
// No error at compile time
type EventHandler = {
  on: (event: string, cb: () => void) => void;
};

const emitter: EventHandler = getEmitter();
emitter.on("clck", handleClick);
// Typo not caught, handler never fires
// No error at compile time

Prefer
type EventName =
  | `on${"Click" | "Hover" | "Focus"}`
  | `on${"Key" | "Mouse"}${"Down" | "Up"}`;

type EventHandler = {
  on: (event: EventName, cb: () => void) => void;
};

const emitter: EventHandler = getEmitter();
emitter.on("onClick", handleClick);
// emitter.on("onClck", handleClick);
// Error: not assignable to EventName
type EventName =
  | `on${"Click" | "Hover" | "Focus"}`
  | `on${"Key" | "Mouse"}${"Down" | "Up"}`;

type EventHandler = {
  on: (event: EventName, cb: () => void) => void;
};

const emitter: EventHandler = getEmitter();
emitter.on("onClick", handleClick);
// emitter.on("onClck", handleClick);
// Error: not assignable to EventName
Why avoid

Using a plain string type for event names provides no protection against typos. A misspelled event name compiles without errors but silently fails at runtime because the handler is never triggered. Template literal types make the valid set of strings explicit.

Why prefer

Template literal types let you define string patterns the compiler enforces. By combining literal unions inside template positions, TypeScript generates all valid combinations and catches typos at compile time instead of silently failing at runtime.

TypeScript: Template Literal Types
Avoid
function navigate(path: string) {
  router.push(path);
}

// No validation on path format
navigate("users/123");
// Missing leading slash, breaks routing
navigate("/uesrs/123");
// Typo in segment, silent 404
function navigate(path: string) {
  router.push(path);
}

// No validation on path format
navigate("users/123");
// Missing leading slash, breaks routing
navigate("/uesrs/123");
// Typo in segment, silent 404

Prefer
type AppRoute =
  | `/users/${number}`
  | `/posts/${number}`
  | `/settings/${"profile" | "billing"}`;

function navigate(path: AppRoute) {
  router.push(path);
}

navigate("/users/123"); // OK
// navigate("users/123"); // Error
// navigate("/uesrs/123"); // Error
type AppRoute =
  | `/users/${number}`
  | `/posts/${number}`
  | `/settings/${"profile" | "billing"}`;

function navigate(path: AppRoute) {
  router.push(path);
}

navigate("/users/123"); // OK
// navigate("users/123"); // Error
// navigate("/uesrs/123"); // Error
Why avoid

Accepting any string for navigation paths means typos, missing slashes, and invalid routes all compile without errors. These bugs are only discovered when users see 404 pages at runtime. Template literal types catch the entire class of invalid route strings.

Why prefer

Template literal types can encode the structure of URL paths. The compiler checks that the path starts with a slash, uses valid segments, and has parameters of the right type. Typos and missing slashes become compile-time errors.

TypeScript: Template Literal Types
Avoid
type Getters<T> = {
  [K in keyof T as `get${string}`]: () => T[K];
};

// Key is just `get${string}`, no link
// to the original property name.
// get_anything would match.
// No casing convention enforced.
type Getters<T> = {
  [K in keyof T as `get${string}`]: () => T[K];
};

// Key is just `get${string}`, no link
// to the original property name.
// get_anything would match.
// No casing convention enforced.

Prefer
type Getters<T> = {
  [K in keyof T as
    `get${Capitalize<string & K>}`
  ]: () => T[K];
};

interface Person { name: string; age: number }
type PersonGetters = Getters<Person>;
// { getName: () => string;
//   getAge: () => number }
type Getters<T> = {
  [K in keyof T as
    `get${Capitalize<string & K>}`
  ]: () => T[K];
};

interface Person { name: string; age: number }
type PersonGetters = Getters<Person>;
// { getName: () => string;
//   getAge: () => number }
Why avoid

Using a raw template literal like get${string} accepts any string after 'get' and loses the connection to the original key. The resulting type cannot enforce camelCase naming or map each getter back to its specific property type.

Why prefer

TypeScript provides intrinsic string manipulation types: Uppercase, Lowercase, Capitalize, and Uncapitalize. Using Capitalize in a mapped type with template literals transforms property names to follow conventional getter naming (getName, getAge) while preserving the type relationship.

TypeScript: String Manipulation Types
Avoid
type Color = "red" | "green" | "blue";
type Shade = "light" | "dark";

// Manually listing all combinations
type Theme =
  | "light-red" | "light-green" | "light-blue"
  | "dark-red" | "dark-green" | "dark-blue";
// Doesn't scale. Forgot a combo?
// Adding a color means updating manually.
type Color = "red" | "green" | "blue";
type Shade = "light" | "dark";

// Manually listing all combinations
type Theme =
  | "light-red" | "light-green" | "light-blue"
  | "dark-red" | "dark-green" | "dark-blue";
// Doesn't scale. Forgot a combo?
// Adding a color means updating manually.

Prefer
type Color = "red" | "green" | "blue";
type Shade = "light" | "dark";

// Template literal distributes over unions
type Theme = `${Shade}-${Color}`;
// Expands to:
// "light-red" | "light-green" |
// "light-blue" | "dark-red" |
// "dark-green" | "dark-blue"
// Adding a new Color auto-expands Theme
type Color = "red" | "green" | "blue";
type Shade = "light" | "dark";

// Template literal distributes over unions
type Theme = `${Shade}-${Color}`;
// Expands to:
// "light-red" | "light-green" |
// "light-blue" | "dark-red" |
// "dark-green" | "dark-blue"
// Adding a new Color auto-expands Theme
Why avoid

Manually enumerating all combinations of two union types is tedious and error-prone. If you add a new color, you must remember to add entries for every shade. Template literal types generate the full cross-product automatically, and they stay in sync as unions change.

Why prefer

When a template literal type contains union types, TypeScript distributes across them and generates every combination. Adding a new member to either union automatically expands the result. This removes the manual maintenance burden and eliminates the risk of missing a combination.

TypeScript: Template Literal Types
Avoid
function sendEmail(email: string) {
  mailer.send(email, template);
}

// Both compile, but one is wrong
sendEmail("user@example.com");
sendEmail("not an email at all");
// No way to distinguish validated
// strings from arbitrary strings
function sendEmail(email: string) {
  mailer.send(email, template);
}

// Both compile, but one is wrong
sendEmail("user@example.com");
sendEmail("not an email at all");
// No way to distinguish validated
// strings from arbitrary strings

Prefer
type Email = string & { __brand: "Email" };

function validateEmail(input: string): Email | null {
  const re = /^[^@]+@[^@]+\.[^@]+$/;
  return re.test(input) ? (input as Email) : null;
}

function sendEmail(email: Email) {
  mailer.send(email, template);
}

const email = validateEmail(userInput);
if (email) sendEmail(email); // OK
// sendEmail("raw string"); // Error
type Email = string & { __brand: "Email" };

function validateEmail(input: string): Email | null {
  const re = /^[^@]+@[^@]+\.[^@]+$/;
  return re.test(input) ? (input as Email) : null;
}

function sendEmail(email: Email) {
  mailer.send(email, template);
}

const email = validateEmail(userInput);
if (email) sendEmail(email); // OK
// sendEmail("raw string"); // Error
Why avoid

Plain string types make no distinction between validated and unvalidated data. Any string, including empty strings and garbage input, can be passed to sendEmail. Branded types force all string data through a validation boundary before it can be used in type-safe contexts.

Why prefer

Branded types use an intersection with a phantom property to create a nominal subtype of string. The only way to obtain an Email value is through the validateEmail function, so sendEmail can trust that its input has already been validated. The brand property exists only at the type level.

Total TypeScript: Branded Types
Avoid
interface APIResponse {
  user_id: number;
  user_name: string;
  user_email: string;
  post_count: number;
}

// Manual snake_case to camelCase mapping
interface ClientData {
  userId: number;
  userName: string;
  userEmail: string;
  postCount: number;
  // Must update both types in sync
}
interface APIResponse {
  user_id: number;
  user_name: string;
  user_email: string;
  post_count: number;
}

// Manual snake_case to camelCase mapping
interface ClientData {
  userId: number;
  userName: string;
  userEmail: string;
  postCount: number;
  // Must update both types in sync
}

Prefer
type SnakeToCamel<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
    : S;

type CamelKeys<T> = {
  [K in keyof T
    as SnakeToCamel<string & K>]: T[K];
};

interface APIResponse {
  user_id: number;
  user_name: string;
  user_email: string;
  post_count: number;
}

type ClientData = CamelKeys<APIResponse>;
// { userId: number; userName: string;
//   userEmail: string; postCount: number }
type SnakeToCamel<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
    : S;

type CamelKeys<T> = {
  [K in keyof T
    as SnakeToCamel<string & K>]: T[K];
};

interface APIResponse {
  user_id: number;
  user_name: string;
  user_email: string;
  post_count: number;
}

type ClientData = CamelKeys<APIResponse>;
// { userId: number; userName: string;
//   userEmail: string; postCount: number }
Why avoid

Maintaining two separate interfaces that must stay in sync is a source of silent bugs. When a new field is added to the API response, forgetting to add it to the client type means the data is available at runtime but invisible to TypeScript. A computed type transformation eliminates this drift.

Why prefer

By combining template literal types with infer, you can write a recursive type that splits a string at underscores and capitalizes each subsequent segment. The CamelKeys mapped type transforms all keys automatically, so adding a new field to APIResponse updates ClientData with zero manual effort.

TypeScript: Template Literal Types