Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 047 / 100

Template literal types: a practical guide for developers

String patterns at the type level.

Expona Admin9 min read

Template literal types let you build type-safe strings by composing patterns at the type level. I've used them extensively to enforce event naming conventions, route structures, and API contracts across large platforms. Once you understand how they work, they become an indispensable tool for catching naming mismatches before runtime.

What are template literal types?

Template literal types are a TypeScript feature that lets you define string patterns using the same syntax as template literals, but at compile time. Instead of creating runtime strings, you're creating a set of allowed string values that conform to a pattern.

Here's the simplest example:

typescript
type Status = "pending" | "active" | "completed";
type Event = `${Status}Changed`;

// Equivalent to: type Event = "pendingChanged" | "activeChanged" | "completedChanged"
const evt: Event = "pendingChanged"; // ✓ OK
const bad: Event = "unknownChanged"; // ✗ Error

The power emerges when you combine this with union types, generics, and mapped types. You're not just interpolating values—you're building a type system that enforces patterns across your codebase.

Build types like ${Entity}.${Action}

One of the most practical applications I've seen is enforcing a consistent naming scheme for domain events. Imagine a trading platform where events follow the pattern Entity.Action. Using template literal types, you can make this pattern unbreakable.

typescript
type Entity = "Order" | "Portfolio" | "Trade" | "Account";
type Action = "Created" | "Updated" | "Deleted" | "Executed";

type DomainEvent = `${Entity}.${Action}`;

// Expands to a union of 16 combinations:
// "Order.Created" | "Order.Updated" | ... | "Account.Executed"

function publishEvent(event: DomainEvent, payload: unknown) {
  console.log(`Publishing: ${event}`, payload);
}

publishEvent("Order.Created", { orderId: 123 }); // ✓ OK
publishEvent("Trade.Executed", { tradeId: 456 }); // ✓ OK
publishEvent("Order.Removed", {}); // ✗ Error: not a valid event
publishEvent("order.created", {}); // ✗ Error: case mismatch

This approach scales beautifully. If you add a new entity or action, every place that consumes DomainEvent gets immediate feedback. No more hunting for typos in event names at runtime.

You can extend this further by excluding certain combinations:

typescript
type ValidEvents = DomainEvent extends `Portfolio.${Action}` 
  ? DomainEvent extends `Portfolio.Deleted` 
    ? never 
    : DomainEvent 
  : DomainEvent;

// Portfolio.Deleted is now invalid
publishEvent("Portfolio.Deleted", {}); // ✗ Error

Type-safe event names

I've built event-driven systems on platforms like Kafka where event naming consistency is critical. Template literal types make it almost impossible to publish an event with a typo.

Consider a ride-hailing platform:

typescript
type RideEventType = 
  | `ride.${Extract}.occurred`
  | `driver.${Extract}.changed`;

type RideStatus = "requested" | "accepted" | "in_progress" | "completed" | "cancelled";
type DriverStatus = "online" | "offline" | "on_trip";

class EventBus {
  private listeners: Map void>> = new Map();

  on(event: RideEventType, handler: (payload: any) => void) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(handler);
  }

  emit(event: RideEventType, payload: any) {
    this.listeners.get(event)?.forEach(handler => handler(payload));
  }
}

const bus = new EventBus();
bus.on("ride.accepted.occurred", (p) => console.log("Ride accepted", p)); // ✓ OK
bus.on("ride.unknown.occurred", (p) => {}); // ✗ Error
bus.emit("driver.online.changed", { driverId: "d1" }); // ✓ OK

With this setup, your IDE autocompletes valid event names, and TypeScript catches misspellings instantly. The event schema is enforced at the type level without any runtime overhead.

Typed route paths

API routing is another area where I've found template literal types invaluable. Building a type-safe router means routes and handlers can't drift out of sync.

typescript
type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type Resource = "users" | "orders" | "products";
type Action = "list" | "create" | "read" | "update" | "delete";

type RoutePath = `/${Resource}` | `/${Resource}/:id` | `/${Resource}/:id/${Resource}`;

type Route = {
  method: Method;
  path: RoutePath;
  handler: (req: any, res: any) => void;
};

class Router {
  private routes: Route[] = [];

  register(method: Method, path: RoutePath, handler: (req: any, res: any) => void) {
    this.routes.push({ method, path, handler });
  }

  get(path: RoutePath, handler: (req: any, res: any) => void) {
    this.register("GET", path, handler);
  }

  post(path: RoutePath, handler: (req: any, res: any) => void) {
    this.register("POST", path, handler);
  }
}

const router = new Router();
router.get("/users", (req, res) => {}); // ✓ OK
router.get("/users/:id", (req, res) => {}); // ✓ OK
router.post("/products", (req, res) => {}); // ✓ OK
router.get("/invalid-route", (req, res) => {}); // ✗ Error

You can push this further by extracting path parameters into a separate type, enabling type-safe parameter access:

typescript
type ExtractParams = T extends `${string}:${infer Param}/${infer Rest}`
  ? Param | ExtractParams
  : T extends `${string}:${infer Param}`
    ? Param
    : never;

type UserParams = ExtractParams<"/users/:userId/posts/:postId">; // "userId" | "postId"

const handler = (req: { params: Record }) => {
  console.log(req.params.userId); // ✓ OK
  console.log(req.params.invalidParam); // ✗ Error
};

Combine with mapped types

The real magic happens when you combine template literal types with mapped types. This lets you generate entire type interfaces from a single source of truth.

I used this pattern in a CRM platform to synchronize entity event handlers with their type definitions:

typescript
type Entities = {
  User: { id: string; email: string };
  Contact: { id: string; phone: string };
  Company: { id: string; name: string };
};

type EventActions = "created" | "updated" | "deleted";

type EventMap = {
  [K in keyof Entities as `${string & K}.${EventActions}`]: Entities[K];
};

// Expands to:
// {
//   "User.created": { id: string; email: string };
//   "User.updated": { id: string; email: string };
//   "User.deleted": { id: string; email: string };
//   "Contact.created": { ... };
//   ...
// }

class EventDispatcher {
  private handlers: Map void> = new Map();

  on(event: K, handler: (payload: EventMap[K]) => void) {
    this.handlers.set(event as string, handler);
  }

  emit(event: K, payload: EventMap[K]) {
    this.handlers.get(event as string)?.(payload);
  }
}

const dispatcher = new EventDispatcher();

dispatcher.on("User.created", (user) => {
  console.log(user.email); // ✓ Typed as User
});

dispatcher.on("Contact.updated", (contact) => {
  console.log(contact.phone); // ✓ Typed as Contact
});

dispatcher.emit("User.created", { id: "u1", email: "john@example.com" }); // ✓ OK
dispatcher.emit("User.created", { id: "u1", phone: "123" }); // ✗ Error: wrong payload type

This pattern ensures that every event emitted has the correct payload structure. If you add a new entity or change a property, TypeScript will flag all the places where event handlers need updates.

Common mistakes

Mistake 1: Over-constraining union types. When building template literal types, don't create unions so large they become unmanageable. A union with 100+ possible values defeats the purpose of type safety because autocomplete becomes useless.

typescript
// Bad: creates 10,000+ combinations
type Event = `${VeryLargeUnion1}.${VeryLargeUnion2}.${VeryLargeUnion3}`;

// Good: keep unions focused
type CoreEvent = `${Entity}.${Action}`;
type ExtendedEvent = CoreEvent | `${CustomPrefix}:${CustomSuffix}`;

Mistake 2: Forgetting that template literal types are still unions. TypeScript expands template literal types into unions. If you try to use them in a way that requires a single string literal, you'll get an error.

typescript
type Event = `${Status}.${Action}`;

// This works:
function handle(e: Event) { }
handle("active.created");

// This doesn't work:
const event: Event = "active.created";
const msg = `The event is ${event}`; // ✗ Error: Type 'Event' is not assignable to type 'string'

// Solution:
const event: Event = "active.created";
const msg = `The event is ${event as string}`;

Mistake 3: Using template literal types when a string literal type suffices. If you only have 5 valid values, a union of string literals is simpler and more performant than a complex template type.

typescript
// Overkill:
type Status = "active" | "pending" | "done";
type Event = `${Status}Changed`;

// Just use this:
type Event = "activeChanged" | "pendingChanged" | "doneChanged";

Key takeaways

  • Template literal types enforce string patterns at compile time by interpolating union types into new unions.
  • Use them for event names, route paths, and domain-specific conventions to catch naming errors before runtime.
  • Combine template literal types with mapped types to generate entire type systems from a single definition.
  • Keep unions focused—large unions make autocomplete worse and type checking slower.
  • Template literal types expand into unions; you can't use them as a single string without casting.
  • They're most valuable when multiple developers work on the same codebase and consistency must be enforced.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles