TypeScript Template Literal Types — Type-Safe Strings in 2024

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

String-based APIs are everywhere in backend code — event names, Redis key prefixes, route paths, permission strings, and database column names. Without template literal types, these are all loosely typed string values with no compiler checking. A typo in an event name silently does nothing; a wrong route constant returns a 404.

Template literal types bring the full power of TypeScript's type system to string patterns. They allow you to define, validate, and generate string types that must match specific patterns — enforced at compile time. Combined with mapped types, they auto-generate event handler names, getter/setter pairs, and entire API surface types from a single source of truth.

In 2024, template literal types are one of the most underutilized TypeScript features in backend codebases, yet they eliminate entire categories of string-related bugs with zero runtime cost.

Basic Template Literal Types

// Combine literal strings
type EventBase = 'user' | 'order' | 'payment';
type EventAction = 'created' | 'updated' | 'deleted';
 
type EventName = `${EventBase}.${EventAction}`;
// 'user.created' | 'user.updated' | 'user.deleted' |
// 'order.created' | 'order.updated' | 'order.deleted' |
// 'payment.created' | 'payment.updated' | 'payment.deleted'
 
// HTTP methods as uppercase
type HttpMethod = Uppercase<'get' | 'post' | 'put' | 'patch' | 'delete'>;
// 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
 
// Type-safe Redis key prefixes
type CacheKey<Resource extends string, Id extends string> = `cache:${Resource}:${Id}`;
type UserCacheKey = CacheKey<'user', string>;
// `cache:user:${string}`
 
function getCacheKey(userId: string): UserCacheKey {
  return `cache:user:${userId}`;
}

String Introspection with infer

Template literal types combined with infer can parse and extract parts of string types.

// Extract route path parameters
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;
 
type UserParams = ExtractParams<'/users/:userId/orders/:orderId'>;
// 'userId' | 'orderId'
 
// Build a type-safe params object from a route pattern
type RouteParams<T extends string> = {
  [K in ExtractParams<T>]: string;
};
 
type UserRouteParams = RouteParams<'/users/:userId/orders/:orderId'>;
// { userId: string; orderId: string }
 
// Parse event namespace
type ExtractNamespace<T extends string> =
  T extends `${infer NS}.${string}` ? NS : never;
 
type Namespace = ExtractNamespace<'user.created' | 'order.shipped' | 'standalone'>;
// 'user' | 'order'

Type-Safe Event System

interface DomainEvents {
  'user.created': { userId: string; email: string; timestamp: string };
  'user.deleted': { userId: string; deletedAt: string };
  'order.placed': { orderId: string; userId: string; total: number };
  'order.shipped': { orderId: string; trackingNumber: string };
  'payment.succeeded': { paymentId: string; amount: number };
  'payment.failed': { paymentId: string; reason: string };
}
 
type EventKey = keyof DomainEvents;
 
// Auto-generate listener method names: onUserCreated, onOrderShipped, etc.
type ListenerName<K extends string> =
  K extends `${infer NS}.${infer Action}`
    ? `on${Capitalize<NS>}${Capitalize<Action>}`
    : `on${Capitalize<K>}`;
 
type EventListeners = {
  [K in EventKey as ListenerName<K>]: (payload: DomainEvents[K]) => void | Promise<void>;
};
 
// EventListeners now looks like:
// { onUserCreated: (payload: ...) => ...; onOrderPlaced: (payload: ...) => ...; }
 
class EventBus {
  private handlers = new Map<string, Array<(payload: unknown) => void>>();
 
  on<K extends EventKey>(event: K, handler: (payload: DomainEvents[K]) => void): void {
    const existing = this.handlers.get(event) ?? [];
    this.handlers.set(event, [...existing, handler as (p: unknown) => void]);
  }
 
  async emit<K extends EventKey>(event: K, payload: DomainEvents[K]): Promise<void> {
    const handlers = this.handlers.get(event) ?? [];
    for (const handler of handlers) {
      await handler(payload);
    }
  }
}

Type-Safe HTTP Route Registry

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type RouteKey = `${HttpMethod} /${string}`;
 
interface RouteRegistry {
  'GET /users': { query: { page?: number; limit?: number } };
  'GET /users/:id': { params: { id: string } };
  'POST /users': { body: { name: string; email: string; password: string } };
  'PUT /users/:id': { params: { id: string }; body: Partial<{ name: string; email: string }> };
  'DELETE /users/:id': { params: { id: string } };
}
 
type MethodOf<R extends string> = R extends `${infer M} ${string}` ? M : never;
type PathOf<R extends string> = R extends `${string} ${infer P}` ? P : never;
 
// Type-safe router
class TypedRouter {
  register<R extends keyof RouteRegistry>(
    route: R,
    handler: (ctx: RouteRegistry[R]) => Promise<unknown>
  ): void {
    const [method, path] = (route as string).split(' ');
    console.log(`Registered ${method} ${path}`);
  }
}
 
const router = new TypedRouter();
router.register('POST /users', async (ctx) => {
  const { name, email, password } = ctx.body;
  return { created: true };
});

Auto-Generated Getter/Setter Types

// Generate getter and setter method names from data model properties
type GetterName<K extends string> = `get${Capitalize<K>}`;
type SetterName<K extends string> = `set${Capitalize<K>}`;
 
type Getters<T> = {
  [K in keyof T & string as GetterName<K>]: () => T[K];
};
 
type Setters<T> = {
  [K in keyof T & string as SetterName<K>]: (value: T[K]) => void;
};
 
interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
}
 
type ProductAccessors = Getters<Product> & Setters<Product>;
// {
//   getId: () => string;
//   getName: () => string;
//   setId: (value: string) => void;
//   setName: (value: string) => void;
//   ...
// }

Environment Variable Types

// Enforce naming conventions on environment variables
type BackendEnvKey =
  | `DB_${Uppercase<string>}`
  | `REDIS_${Uppercase<string>}`
  | `JWT_${Uppercase<string>}`
  | `PORT`
  | `NODE_ENV`;
 
// Type-safe environment variable accessor
function getEnv<K extends BackendEnvKey>(key: K): string {
  const value = process.env[key];
  if (!value) throw new Error(`Missing environment variable: ${key}`);
  return value;
}
 
const dbUrl = getEnv('DB_URL');          // valid
const jwtSecret = getEnv('JWT_SECRET');  // valid
// getEnv('api_key');  // Error: 'api_key' doesn't match the pattern

Common Mistakes

  • Overcomplicating route type patterns — template literal types are great for simple patterns but hard to debug when complex
  • Forgetting that Capitalize<K> requires K extends string — use string & K with keyof types
  • Using template literal types where a simple union would be clearer and equally correct
  • Applying complex template literal type inference in hot code paths — the types are compile-only but complex files can be slow
  • Not testing that generated type combinations match expected values using explicit type assertions

Best Practices

  • Use template literal types for string constants that follow a predictable pattern (event names, cache keys, permissions)
  • Combine with Capitalize, Lowercase, and Uppercase utilities for consistent naming conventions
  • Use infer within template literal extends clauses to parse and extract string segments
  • Prefer generating types over hand-coding all string union members — one source of truth
  • Test generated types by assigning known values and checking them in your IDE

Key Takeaways

  • Template literal types create string union types by combining other string literal types
  • Uppercase<T>, Lowercase<T>, Capitalize<T>, and Uncapitalize<T> transform string literal types
  • infer inside template literal extends clauses extracts substrings as named type variables
  • Combined with mapped types, template literals auto-generate event handler names and accessor methods
  • Route parameter types like RouteParams<'/users/:id'> can be derived automatically from route strings
  • All template literal type computation is compile-time only — zero runtime JavaScript is generated
  • Environment variable types, cache key patterns, and permission strings benefit most from template literal constraints
  • Complex template literal patterns with many union combinations can slow TypeScript compilation — keep them focused

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading