Skip to main content
TypeScript intermediate Lesson 7 of 21

Type Aliases in TypeScript

Use the type keyword to create unions, intersections, discriminated unions, and mapped types for expressive and reusable TypeScript code.

The type Keyword

The type keyword lets you give a name to any type expression — not just object shapes. This is the primary tool for building reusable, composable type vocabulary in your codebase. Instead of repeating complex type expressions everywhere, you define them once and reference them by name.

type UserId = number;
type UserName = string;
type Callback = () => void;
type MaybeString = string | null;

Type aliases are transparent to the type system. UserId and number are interchangeable — TypeScript does not treat them as distinct nominal types. If you need nominal behavior, see branded types in the advanced types tutorial.

Union Types

A union type expresses that a value can be one of several types. This is one of the most useful features in TypeScript — it makes optional or multi-form values explicit in the type system rather than leaving them as implicit runtime surprises.

type StringOrNumber = string | number;

function format(value: StringOrNumber): string {
  if (typeof value === "string") {
    return value;
  }
  return value.toFixed(2);
}

Union of literal string types is especially powerful. It creates a closed set of valid values — the TypeScript equivalent of an enum, but more lightweight:

type Direction = "north" | "south" | "east" | "west";
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type LogLevel = "debug" | "info" | "warn" | "error";

function log(level: LogLevel, message: string): void {
  console.log(`[${level.toUpperCase()}] ${message}`);
}

log("info", "Server started");
log("verbose", "detail"); // Error: "verbose" is not assignable to type 'LogLevel'

Intersection Types

An intersection type combines multiple types into one. The resulting value must satisfy all of them simultaneously — it has every property from every member. Intersections are useful for composing types from smaller, focused pieces.

type Entity = {
  id: number;
  createdAt: Date;
};

type UserData = {
  name: string;
  email: string;
};

// User has all four fields: id, createdAt, name, email
type User = Entity & UserData;

const user: User = {
  id: 1,
  createdAt: new Date(),
  name: "Alice",
  email: "[email protected]",
};

A common pattern is composing request types by intersecting the base type with middleware-added properties:

type WithAuth = { userId: string; roles: string[] };
type WithLogging = { requestId: string; timestamp: Date };

// An authenticated, logged request has all the properties
type AuthenticatedRequest = Request & WithAuth & WithLogging;

Discriminated Unions

A discriminated union is a pattern where each member of a union has a shared literal property — the discriminant — that uniquely identifies which variant you’re dealing with. TypeScript uses this property to narrow the type precisely in each branch, giving you access to only the fields that exist on that variant.

This pattern is the idiomatic way to model state machines, API responses, and any data that can be in one of several distinct states.

type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: User[] };
type ErrorState   = { status: "error"; message: string };

type FetchState = LoadingState | SuccessState | ErrorState;

function renderState(state: FetchState): string {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      // TypeScript narrows to SuccessState here — state.data is available
      return `Found ${state.data.length} users`;
    case "error":
      // TypeScript narrows to ErrorState here — state.message is available
      return `Error: ${state.message}`;
  }
}

Another common example — a Result type that forces callers to handle both success and failure:

type Ok<T>  = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return { ok: false, error: "Division by zero" };
  return { ok: true, value: a / b };
}

const result = divide(10, 2);
if (result.ok) {
  console.log(result.value); // number — TypeScript knows this branch is Ok<number>
} else {
  console.log(result.error); // string — TypeScript knows this branch is Err<string>
}

Mapped Types

Mapped types create new types by iterating over the keys of an existing type and transforming each property. This is how TypeScript’s built-in utility types like Partial, Required, and Readonly are actually implemented — understanding mapped types lets you build your own.

// Make all properties optional — this is exactly how Partial<T> is defined
type Partial<T> = {
  [K in keyof T]?: T[K];
};

// Make all properties required — the -? removes the optional modifier
type Required<T> = {
  [K in keyof T]-?: T[K];
};

// Make all properties readonly
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

A custom mapped type that converts all values to strings — useful for serialization layers:

type Stringify<T> = {
  [K in keyof T]: string;
};

type UserStrings = Stringify<User>;
// { id: string; name: string; email: string }

Key remapping with as lets you filter which keys are included — keeping only properties that match a condition:

// Keep only properties whose value type extends string
type StringProperties<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

interface Mixed {
  name: string;
  age: number;
  email: string;
  active: boolean;
}

type StringOnly = StringProperties<Mixed>;
// { name: string; email: string }

Conditional Types

Conditional types express type-level if/else logic, letting you build types that change shape based on their inputs. They’re the building block behind many advanced utility types and are particularly useful for creating flexible, self-adapting type relationships.

type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;  // "yes"
type B = IsString<number>;  // "no"

A practical use — unwrapping a Promise type to get the resolved value:

type Awaited<T> = T extends Promise<infer U> ? U : T;

type A = Awaited<Promise<string>>;  // string
type B = Awaited<number>;           // number (non-promise passes through unchanged)

Template Literal Types

Template literal types apply JavaScript’s template literal syntax at the type level, letting you construct string types by combining other string types. This is useful for typed event names, CSS property names, API route patterns, and any case where string structure carries semantic meaning.

type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

type CSSUnit = "px" | "em" | "rem" | "%";
type CSSValue = `${number}${CSSUnit}`;

function css(value: CSSValue): string {
  return value;
}

css("16px");  // fine
css("16vw");  // Error: "16vw" is not assignable to CSSValue

Recursive Type Aliases

Types can reference themselves, which lets you model recursive data structures precisely. Without recursive types, you’d have to settle for any or object for nested structures like JSON or trees.

// Models any valid JSON value — including deeply nested structures
type JSONValue =
  | string
  | number
  | boolean
  | null
  | JSONValue[]
  | { [key: string]: JSONValue };

const config: JSONValue = {
  name: "app",
  version: 1,
  settings: {
    debug: true,
    tags: ["web", "api"],
  },
};

A recursive tree structure for hierarchical data:

type TreeNode<T> = {
  value: T;
  children: TreeNode<T>[]; // references itself — TypeScript handles this correctly
};

const tree: TreeNode<string> = {
  value: "root",
  children: [
    { value: "a", children: [] },
    { value: "b", children: [{ value: "b1", children: [] }] },
  ],
};

Practical Example: API Route Types

This example pulls together unions, mapped types, and template literal types to build a fully typed API routing system where invalid paths and mismatched params are caught at compile time.

type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";

// Extract :param segments from a path string as a union of string keys
type RouteParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof RouteParams<`/${Rest}`>]: string }
    : Path extends `${string}:${infer Param}`
    ? { [K in Param]: string }
    : Record<string, never>;

type Route<Path extends string, Body = void> = {
  method: HttpMethod;
  path: Path;
  params: RouteParams<Path>; // params type is derived from the path string
  body: Body;
};

// params is automatically typed as { id: string }
type UserRoute = Route<"/users/:id", never>;

// params is empty, body is typed
type CreateUserRoute = Route<"/users", { name: string; email: string }>;

Frequently Asked Questions

What is the difference between a union type and an intersection type?
A union type (A | B) means a value can be either A or B. An intersection type (A & B) means a value must satisfy both A and B simultaneously — it has all properties of both.
What is a discriminated union?
A discriminated union is a union of types that each have a common literal property (the discriminant). TypeScript uses that property to narrow the type in switch/if statements.
What are mapped types?
Mapped types transform existing types by iterating over their keys. TypeScript's built-in utility types like Partial, Required, and Readonly are all implemented as mapped types.