Type Aliases
Type aliases vs interfaces, recursive types, template literal types, and discriminated unions.
A type alias gives a name to any type expression - a primitive, a union, an object shape, a function signature, or a combination of all of these. Aliases are more flexible than interfaces and are the right tool when the shape shouldn't be extended or merged.
type gives a name to any type, including unions and intersections that interfaces can't express.
type ID = string | number;
type Nullable<T> = T | null;
type Point = { x: number; y: number };
type Callback = (error: Error | null, result?: string) => void;
// Intersection - combines two shapes into one
type TimestampedPoint = Point & { timestamp: number };
const p: TimestampedPoint = { x: 1, y: 2, timestamp: Date.now() };Recursive types reference themselves. A tree node or nested JSON structure are the canonical cases.
type TreeNode = {
value: number;
left?: TreeNode;
right?: TreeNode;
};
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
const tree: TreeNode = {
value: 1,
left: { value: 2 },
right: { value: 3, left: { value: 4 } },
};Template literal types compose string literal types the same way template literals compose strings. They're used to derive event names, route patterns, or any string that follows a predictable structure.
type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`;
// HandlerName = "onClick" | "onFocus" | "onBlur"
type Route = "/users" | "/posts" | "/comments";
type ApiRoute = `/api${Route}`;
// ApiRoute = "/api/users" | "/api/posts" | "/api/comments"A discriminated union uses a shared literal field (type) to let TypeScript narrow the active variant in a switch statement. Each branch gets the exact shape for that variant - no optional fields, no casting.
type Action =
| { type: "ADD_ITEM"; payload: { id: string; name: string } }
| { type: "REMOVE_ITEM"; id: string }
| { type: "CLEAR" };
function reducer(state: string[], action: Action): string[] {
switch (action.type) {
case "ADD_ITEM":
return [...state, action.payload.name]; // action.payload is typed here
case "REMOVE_ITEM":
return state.filter((_, i) => i !== parseInt(action.id)); // action.id is typed here
case "CLEAR":
return [];
}
}In production
Discriminated unions replace most class hierarchies in TypeScript codebases. They serialize cleanly to JSON (no methods, no prototype chains), work naturally with Redux and XState, and make adding new variants safe - the compiler forces you to handle every case in every switch. The key discipline: always include a default: const _: never = action exhaustiveness check so that adding a new variant to the union without updating every switch is a compile error, not a silent runtime bug.
Enjoyed this? Get more essays on software craft delivered to your inbox.
Subscribe free