Skip to main content
TypeScript · lesson 7 of 21
TypeScript intermediate Lesson 7 of 21

Type Aliases in TypeScript

The type keyword: unions, intersections, discriminated unions, mapped, conditional, template literal and recursive types — with real tsc and tsx output.

By SkillByExample · Updated

Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default — on 5.x add --strict to get the same errors.

The type Keyword

type gives a name to any type expression — a primitive, a union, a function signature, an object shape. The name is an alias, not a new type.

type UserId = number;
type OrderId = number;
type Callback = (value: string) => void;
type MaybeString = string | null;

const user: UserId = 42;
const order: OrderId = user;
const log: Callback = (v) => console.log(`got ${v}`);
const title: MaybeString = null;

log(String(order));
console.log(title ?? "(untitled)");
$ npx tsc --noEmit --module nodenext alias.ts
$ npx tsx alias.ts
got 42
(untitled)

Assigning a UserId to an OrderId compiles because both are just number. Aliases document intent; they don’t enforce it. If mixing up IDs is a real risk, brand them:

type UserId = number & { readonly __brand: "UserId" };
type OrderId = number & { readonly __brand: "OrderId" };

const toUserId = (n: number) => n as UserId;

const user = toUserId(42);
const order: OrderId = user;
const plain: UserId = 42;
$ npx tsc --noEmit --module nodenext brand.ts
brand.ts(7,7): error TS2322: Type 'UserId' is not assignable to type 'OrderId'.
  Type 'UserId' is not assignable to type '{ readonly __brand: "OrderId"; }'.
    Types of property '__brand' are incompatible.
      Type '"UserId"' is not assignable to type '"OrderId"'.
brand.ts(8,7): error TS2322: Type 'number' is not assignable to type 'UserId'.
  Type 'number' is not assignable to type '{ readonly __brand: "UserId"; }'.

The brand property never exists at runtime — it’s a compile-time tag, and the as in toUserId is the one place a plain number is allowed to become a UserId.

Union Types

A | B means “either”. A union of string literals is a closed set of allowed values:

type LogLevel = "debug" | "info" | "warn" | "error";

function log(level: LogLevel, message: string | number): void {
  const text = typeof message === "number" ? message.toFixed(2) : message;
  console.log(`[${level.toUpperCase()}] ${text}`);
}

log("info", "Server started");
log("warn", 0.5);
log("verbose", "detail");
$ npx tsc --noEmit --module nodenext unions.ts
unions.ts(10,5): error TS2345: Argument of type '"verbose"' is not assignable to parameter of type 'LogLevel'.

Inside log, message is string | number, so calling toFixed directly would be an error — the typeof check narrows it first. Remove the "verbose" call:

$ npx tsx unions.ts
[INFO] Server started
[WARN] 0.50

Intersection Types

A & B means “both”: a value must have every property of every member.

type Entity = { id: number; createdAt: Date };
type UserData = { name: string; email: string };
type User = Entity & UserData;

const user: User = {
  id: 1,
  createdAt: new Date(0),
  name: "Alice",
  email: "alice@example.com",
};
console.log(Object.keys(user));

const partial: User = { id: 2, name: "Bob" };
$ npx tsc --noEmit --module nodenext intersections.ts
intersections.ts(13,7): error TS2322: Type '{ id: number; name: string; }' is not assignable to type 'User'.
  Property 'createdAt' is missing in type '{ id: number; name: string; }' but required in type 'Entity'.

The error names createdAt and says it’s required by Entity — tsc checks an intersection one member at a time and reports the first one that fails, here Entity. Add createdAt and the error moves on to email, required by UserData. Either way, the value has to satisfy both halves. Intersections are how you compose small shared shapes (Entity, WithTimestamps) into bigger ones.

Discriminated Unions

Give every member of a union a shared literal property — the discriminant — and checking it narrows the whole object.

type FetchState =
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function render(state: FetchState): string {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return `Found ${state.data.length} users`;
    case "error":
      return `Error: ${state.message}`;
  }
}

const states: FetchState[] = [
  { status: "loading" },
  { status: "success", data: ["Alice", "Bob"] },
  { status: "error", message: "timeout" },
];
states.forEach((s) => console.log(render(s)));
$ npx tsc --noEmit --module nodenext fetch-state.ts
$ npx tsx fetch-state.ts
Loading...
Found 2 users
Error: timeout

state.data is only reachable in the "success" case and state.message only in "error" — reading state.data in the "error" branch is a compile error. And although render has no return after the switch, it compiles: every member of the union is handled, so the end of the function is unreachable. The same pattern gives a Result type that forces callers to handle failure:

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

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

for (const [a, b] of [[10, 4], [1, 0]] as const) {
  const r = divide(a, b);
  console.log(r.ok ? `value ${r.value}` : `error ${r.error}`);
}

const r = divide(1, 0);
console.log(r.value);
$ npx tsc --noEmit --module nodenext result.ts
result.ts(14,15): error TS2339: Property 'value' does not exist on type 'Result<number, string>'.
  Property 'value' does not exist on type '{ ok: false; error: string; }'.

Before checking r.ok, r might be the error variant, which has no value. Remove the last two lines:

$ npx tsx result.ts
value 2.5
error division by zero

Mapped Types

A mapped type loops over the keys of another type: { [K in keyof T]: ... }. Partial, Required and Readonly are all built this way.

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

type Stringify<T> = { [K in keyof T]: string };
type MyPartial<T> = { [K in keyof T]?: T[K] };
type StringProps<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K] };

const form: Stringify<Mixed> = { name: "Alice", age: "30", email: "a@x.io", active: "yes" };
const patch: MyPartial<Mixed> = { age: 31 };
console.log(form, patch);

const probe: StringProps<Mixed> = {};
const bad: Stringify<Mixed> = { ...form, age: 30 };
$ npx tsc --noEmit --module nodenext mapped.ts
mapped.ts(11,7): error TS2739: Type '{}' is missing the following properties from type 'StringProps<Mixed>': name, email
mapped.ts(12,42): error TS2322: Type 'number' is not assignable to type 'string'.

The two deliberate errors reveal the mapped types. Assigning {} to StringProps<Mixed> lists what’s required — only name and email, because the as clause remapped the other keys to never, which drops them. Stringify turned every property into string, so age: 30 no longer fits. Deliberately-wrong assignments like probe are a handy way to make tsc print a type for you.

Conditional Types

T extends U ? X : Y picks a type based on another type. infer captures part of the matched type.

type IsString<T> = T extends string ? "yes" : "no";
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type ElementOf<T> = T extends (infer E)[] ? E : never;

const a: IsString<"hi"> = "no";
const b: IsString<42> = "yes";
const c: Unwrap<Promise<string>> = 1;
const d: Unwrap<number> = "x";
const e: ElementOf<boolean[]> = "x";
$ npx tsc --noEmit --module nodenext conditional.ts
conditional.ts(5,7): error TS2322: Type '"no"' is not assignable to type '"yes"'.
conditional.ts(6,7): error TS2322: Type '"yes"' is not assignable to type '"no"'.
conditional.ts(7,7): error TS2322: Type 'number' is not assignable to type 'string'.
conditional.ts(8,7): error TS2322: Type 'string' is not assignable to type 'number'.
conditional.ts(9,7): error TS2322: Type 'string' is not assignable to type 'boolean'.

Every line is wrong on purpose, and each error names the resolved type: IsString<"hi"> is "yes", Unwrap<Promise<string>> is string, and a non-promise passes through Unwrap unchanged. TypeScript ships the production version of Unwrap as Awaited<T>, which also unwraps nested promises.

Template Literal Types

Template literal syntax works on types too, building string types from other string types.

type EventName = "click" | "focus";
type Handler = `on${Capitalize<EventName>}`;

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

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

const h: Handler = "onclick";
console.log(css("16px"), css("1.5rem"));
css("16vw");
css("px");
$ npx tsc --noEmit --module nodenext template.ts
template.ts(11,7): error TS2820: Type '"onclick"' is not assignable to type '"onClick" | "onFocus"'. Did you mean '"onClick"'?
template.ts(13,5): error TS2345: Argument of type '"16vw"' is not assignable to parameter of type '`${number}em` | `${number}px` | `${number}rem` | `${number}%`'.
template.ts(14,5): error TS2345: Argument of type '"px"' is not assignable to parameter of type '`${number}em` | `${number}px` | `${number}rem` | `${number}%`'.

A union inside a template expands to every combination, so Handler is "onClick" | "onFocus" — the error lists them. ${number} accepts any numeric string, including 1.5, but not an empty one, so "px" fails as well as "16vw".

Recursive Type Aliases

A type can refer to itself, which models nested data such as JSON or trees.

type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
type TreeNode<T> = { value: T; children: TreeNode<T>[] };

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

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

function depth<T>(node: TreeNode<T>): number {
  return 1 + Math.max(0, ...node.children.map(depth));
}

console.log(JSON.stringify(config), depth(tree));
const notJson: JSONValue = { when: new Date() };
$ npx tsc --noEmit --module nodenext recursive.ts
recursive.ts(16,7): error TS2322: Type '{ when: Date; }' is not assignable to type 'JSONValue'.
  Types of property 'when' are incompatible.
    Type 'Date' is not assignable to type 'JSONValue | undefined'.
      Type 'Date' is not assignable to type '{ [key: string]: JSONValue; }'.
        Index signature for type 'string' is missing in type 'Date'.

A Date isn’t a JSONValue — JSON.stringify would turn it into a string, so it wouldn’t survive a round trip. Remove the last line:

$ npx tsx recursive.ts
{"name":"app","settings":{"debug":true,"tags":["web","api"]}} 3

A Worked Example: Route Parameters from a Path

Conditional, template literal and mapped types together can derive a params object from a route string.

type RouteParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param]: string } & RouteParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

function buildPath<P extends string>(path: P, params: RouteParams<P>): string {
  const values = params as Record<string, string>;
  return path.replace(/:(\w+)/g, (_, key: string) => values[key] ?? "");
}

console.log(buildPath("/users/:id", { id: "42" }));
console.log(buildPath("/users/:userId/posts/:postId", { userId: "7", postId: "99" }));
console.log(buildPath("/health", {}));

buildPath("/users/:id", {});
buildPath("/users/:userId/posts/:postId", { userId: "7" });
$ npx tsc --noEmit --module nodenext routes.ts
routes.ts(17,25): error TS2345: Argument of type '{}' is not assignable to parameter of type '{ id: string; }'.
  Property 'id' is missing in type '{}' but required in type '{ id: string; }'.
routes.ts(18,43): error TS2345: Argument of type '{ userId: string; }' is not assignable to parameter of type '{ userId: string; } & { postId: string; }'.
  Property 'postId' is missing in type '{ userId: string; }' but required in type '{ postId: string; }'.

RouteParams peels off one :param segment at a time with infer, recurses on the rest, and intersects the results, so the second path requires both userId and postId. Remove the two bad calls:

$ npx tsx routes.ts
/users/42
/users/7/posts/99
/health

The runtime replace doesn’t know about the types — the as Record<string, string> cast bridges them. That’s the usual shape of type-level tricks: the compiler checks call sites precisely, and the implementation stays ordinary JavaScript.

Practice

1. Add a "cancelled" variant with a reason: string to FetchState without touching render. What does tsc report?

Solution
type FetchState =
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string }
  | { status: "cancelled"; reason: string };

function render(state: FetchState): string {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return `Found ${state.data.length} users`;
    case "error":
      return `Error: ${state.message}`;
  }
}
console.log(render({ status: "cancelled", reason: "user" }));
$ npx tsc --noEmit --module nodenext practice-1.ts
practice-1.ts(7,37): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

The switch is no longer exhaustive, so the function can fall off the end and return undefined. The error is on the return type annotation. Adding case "cancelled": return \Cancelled: ${state.reason}`;` fixes it.

2. Write a mapped type Getters<T> that turns { name: string; age: number } into { getName: () => string; getAge: () => number }.

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

type Person = { name: string; age: number };

const p: Getters<Person> = { getName: () => "Alice", getAge: () => 30 };
console.log(p.getName(), p.getAge());

const probe: Getters<Person> = {};
$ npx tsc --noEmit --module nodenext practice-2.ts
practice-2.ts(10,7): error TS2739: Type '{}' is missing the following properties from type 'Getters<Person>': getName, getAge

keyof T & string keeps only string keys (symbols can’t be capitalised), and the as clause renames each one with a template literal type. The probe error lists the generated keys.

3. Write type Flatten<T> that returns the element type for arrays and leaves other types alone, and check Flatten<string[]> and Flatten<number>.

Solution
type Flatten<T> = T extends readonly (infer E)[] ? E : T;

const a: Flatten<string[]> = "ok";
const b: Flatten<number> = 7;
const c: Flatten<string[]> = 7;
console.log(a, b, c);
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(5,7): error TS2322: Type 'number' is not assignable to type 'string'.

a and b check cleanly, and the deliberately wrong c confirms Flatten<string[]> is string. readonly (infer E)[] also matches readonly arrays, which a plain (infer E)[] would not.

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.
Does a type alias create a new type?
No. A type alias is just a name for a type expression, so type UserId = number is interchangeable with number and with any other alias of number. To make two structurally identical types incompatible, add a brand property, for example number & { readonly __brand: "UserId" }.
How can I see what a complex type alias resolves to?
Hover it in an editor, or assign a deliberately wrong value to a variable of that type and read the tsc error. Messages such as 'missing the following properties' or 'not assignable to type "yes"' spell out the resolved type.