TypeScript Type System Fundamentals
TypeScript's type system is the most sophisticated compile-time type system in mainstream use. Understanding it deeply â not just the syntax, but the theory behind decisions â is what lets you architect types that guide teams and catch entire categories of bugs at compile time.
Structural Typing vs Nominal Typing
TypeScript uses structural typing (also called "duck typing" at the type level). Two types are compatible if their shapes match â names don't matter.
interface Dog {
name: string;
bark(): void;
}
interface NoisyThing {
name: string;
bark(): void;
}
const dog: Dog = { name: "Rex", bark() {} };
const thing: NoisyThing = dog; // â
â same shape, different nameIn nominal type systems (Java, C#), Dog and NoisyThing would be incompatible despite identical structure. TypeScript intentionally chose structural typing because JavaScript is inherently structural â objects are bags of properties, and functions accept anything with the right shape.
The Excess Property Check Exception
Structural typing has one deliberate exception: fresh object literals get excess property checking.
interface Config {
host: string;
port: number;
}
// â Error: Object literal may only specify known properties
const config: Config = { host: "localhost", port: 3000, debug: true };
// â
No error â variable assignment bypasses excess property check
const obj = { host: "localhost", port: 3000, debug: true };
const config2: Config = obj;This catches typos and accidental properties in the common case while preserving structural compatibility everywhere else.
type vs interface â When to Use Which
This is one of the most common interview questions, and most answers are shallow. Here's the complete picture:
// Declaration merging â interfaces only
interface Window {
myApp: { version: string };
}
interface Window {
myApp: { version: string; debug: boolean }; // merges with above
}
// Computed properties, unions, intersections â type aliases only
type EventName = "click" | "scroll" | "resize";
type Callback<T> = (data: T) => void;
type Pair<A, B> = [A, B];| Feature | interface | type |
|---|---|---|
| Declaration merging | â | â |
extends / implements | â (slightly faster) | â
(via &) |
| Unions / intersections | â | â |
| Mapped types | â | â |
| Tuple / function types | Awkward | â |
| Error messages | Named | May inline |
The senior answer: Use interface for public API surfaces and object shapes that might be extended. Use type for unions, intersections, mapped types, and anything that isn't a plain object shape. In library code, prefer interface because consumers can augment it.
Union Types and Discriminated Unions
Unions represent "one of several types." But the real power is in discriminated unions â the single most important TypeScript pattern.
// Simple union
type StringOrNumber = string | number;
// Discriminated union â each variant has a literal `kind` property
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
}
}Exhaustive Checking with never
The compiler can prove you've handled every case if the switch is exhaustive:
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
const _exhaustive: never = shape; // â Error if a case is missing
return _exhaustive;
}
}If you add a new variant to Shape but forget to handle it, the never assignment fails at compile time.
Intersection Types
Intersections combine types. Think of them as "AND" (unions are "OR").
type Timestamped = { createdAt: Date; updatedAt: Date };
type SoftDeletable = { deletedAt: Date | null };
type User = { id: string; name: string } & Timestamped & SoftDeletable;
// User has id, name, createdAt, updatedAt, AND deletedAtIntersection of primitives produces never â there's no value that is both string and number:
type Impossible = string & number; // neverLiteral Types and as const
Literal types narrow a type to a specific value:
let direction: "north" | "south" | "east" | "west";
direction = "north"; // â
direction = "up"; // âas const Assertions
as const creates the narrowest possible type â readonly tuples with literal element types:
const routes = {
home: "/",
about: "/about",
user: "/user/:id",
} as const;
// type: { readonly home: "/"; readonly about: "/about"; readonly user: "/user/:id" }
// Without `as const`: { home: string; about: string; user: string }
type Route = (typeof routes)[keyof typeof routes]; // "/" | "/about" | "/user/:id"This is the foundation for type-safe configuration objects, action types, and enum alternatives.
Enums vs Const Objects vs Union Types
Three approaches to the same problem â here's the trade-off analysis:
// Approach 1: enum
enum Direction {
North = "NORTH",
South = "SOUTH",
}
// Approach 2: const object + type extraction
const Direction2 = {
North: "NORTH",
South: "SOUTH",
} as const;
type Direction2 = (typeof Direction2)[keyof typeof Direction2]; // "NORTH" | "SOUTH"
// Approach 3: plain union
type Direction3 = "NORTH" | "SOUTH";enum | const object | union | |
|---|---|---|---|
| Runtime cost | Emits an object | Emits an object | Zero â erased |
| Reverse mapping | â (numeric only) | Manual | â |
| Iteration | Object.values() | Object.values() | â |
| Tree-shakeable | â (unless const enum) | â | â |
| Refactoring | IDE rename works | IDE rename works | Find & replace |
The senior answer: Use plain unions for simple cases. Use as const objects when you need runtime iteration or mapping. Avoid enum in library code â it's a TypeScript-only construct that doesn't interop well with plain JavaScript consumers.
The Type Hierarchy: unknown, any, and never
TypeScript's type system forms a lattice:
unknown â top type (every type is assignable TO unknown)
/ | \
string number boolean ...
\ | /
never â bottom type (never is assignable TO every type)// unknown â safe top type, requires narrowing before use
function process(input: unknown): string {
if (typeof input === "string") return input.toUpperCase();
if (typeof input === "number") return input.toFixed(2);
return String(input);
}
// any â unsafe escape hatch, disables type checking
function dangerous(input: any): string {
return input.foo.bar.baz; // no error, will crash at runtime
}
// never â represents impossibility
function throwError(msg: string): never {
throw new Error(msg); // function never returns
}Key insight: unknown is the type-safe counterpart to any. Use unknown for values whose type you don't know; use any only as a last resort for migration or interop.
Type Widening and Narrowing
Widening is TypeScript inferring a broader type than the literal value:
let x = "hello"; // type: string (widened)
const y = "hello"; // type: "hello" (literal â const prevents widening)
let z: "hello" = "hello"; // type: "hello" (explicit annotation prevents widening)Narrowing is the opposite â refining a type within a control flow branch:
function process(value: string | number) {
if (typeof value === "string") {
// value is narrowed to `string` here
return value.toUpperCase();
}
// value is narrowed to `number` here
return value.toFixed(2);
}Narrowing works with typeof, instanceof, in, equality checks, truthiness checks, and user-defined type guards.
Type Assertions vs Type Annotations
// Annotation â you tell the compiler what type a variable is
const x: number = 42;
// Assertion â you override the compiler's inference
const input = document.getElementById("name") as HTMLInputElement;
// Double assertion â escape hatch for incompatible types (red flag)
const sketchy = ("hello" as unknown) as number;Rule of thumb: Prefer annotations. Use assertions only when you know more than the compiler (e.g., DOM elements). Double assertions are almost always a code smell.
The satisfies Operator (TS 5.0+)
satisfies validates a value against a type without widening the inferred type:
type Color = "red" | "green" | "blue";
type Theme = Record<string, Color | [number, number, number]>;
const theme = {
primary: "red",
secondary: [0, 128, 255],
background: "blue",
} satisfies Theme;
// Without satisfies (using annotation):
// theme.primary would be Color | [number, number, number]
// With satisfies:
theme.primary.toUpperCase(); // â
â TypeScript knows it's "red"
theme.secondary.map(x => x); // â
â TypeScript knows it's [number, number, number]satisfies gives you the best of both worlds: type validation AND precise inference. This is the modern replacement for many as const + annotation patterns.
Index Signatures vs Record
// Index signature â allows any string key
interface StringMap {
[key: string]: number;
}
// Record â essentially the same but as a type alias
type StringMap2 = Record<string, number>;
// Key difference: index signatures allow mixing specific and dynamic keys
interface Config {
version: number;
[key: string]: string | number; // must be compatible with version's type
}Enable noUncheckedIndexedAccess in your tsconfig â it makes index access return T | undefined, catching a huge class of bugs:
// With noUncheckedIndexedAccess: true
const map: Record<string, number> = {};
const value = map["key"]; // type: number | undefined (safe!)Tuple Types
Tuples are fixed-length arrays with specific types at each position:
type Point = [x: number, y: number]; // labeled tuple
type HTTPResponse = [status: number, body: string];
// Variadic tuples (TS 4.0+)
type Head<T extends readonly unknown[]> = T extends [infer H, ...unknown[]] ? H : never;
type Tail<T extends readonly unknown[]> = T extends [unknown, ...infer R] ? R : never;
type First = Head<[1, 2, 3]>; // 1
type Rest = Tail<[1, 2, 3]>; // [2, 3]Optional and Rest Elements
type PartialPoint = [number, number?]; // second element optional
type AtLeastOne = [string, ...string[]]; // one or more strings
type CSV = [header: string[], ...rows: string[][]]; // named restInterview Power Moves
When discussing TypeScript fundamentals, weave in these observations:
-
"TypeScript is a gradual type system" â you can incrementally adopt it, mixing typed and untyped code. This is by design, not a limitation.
-
"Structural typing aligns with JavaScript's runtime behavior" â objects are just property bags at runtime, so the type system reflects that.
-
"The
satisfiesoperator solved a long-standing tension" â between wanting type validation (annotations) and wanting precise inference (no annotation). It's the single most impactful addition in recent TypeScript versions. -
"I use
unknownoveranybecause it forces explicit narrowing" â shows you understand type safety as a practice, not just a syntax. -
"
neverisn't just a curiosity â it's the key to exhaustive checking" â demonstrates you use the type system to prevent runtime bugs. -
"I enable
noUncheckedIndexedAccessandexactOptionalPropertyTypesin every project" â shows you push TypeScript's strictness beyond the defaults.