DNA🔷 TypeScriptTypeScript Type System Fundamentals
ðŸĢHatchlingTypeScriptTypesFundamentals

TypeScript Type System Fundamentals

The bedrock of TypeScript mastery. Structural typing, union and intersection types, the type hierarchy, and every foundational concept that separates competent engineers from senior ones.

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 name

In 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];
Featureinterfacetype
Declaration merging✅❌
extends / implements✅ (slightly faster)✅ (via &)
Unions / intersections❌✅
Mapped types❌✅
Tuple / function typesAwkward✅
Error messagesNamedMay 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 deletedAt

Intersection of primitives produces never — there's no value that is both string and number:

type Impossible = string & number; // never

Literal 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";
enumconst objectunion
Runtime costEmits an objectEmits an objectZero — erased
Reverse mapping✅ (numeric only)Manual❌
IterationObject.values()Object.values()❌
Tree-shakeable❌ (unless const enum)✅✅
RefactoringIDE rename worksIDE rename worksFind & 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 rest

Interview Power Moves

When discussing TypeScript fundamentals, weave in these observations:

  1. "TypeScript is a gradual type system" — you can incrementally adopt it, mixing typed and untyped code. This is by design, not a limitation.

  2. "Structural typing aligns with JavaScript's runtime behavior" — objects are just property bags at runtime, so the type system reflects that.

  3. "The satisfies operator 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.

  4. "I use unknown over any because it forces explicit narrowing" — shows you understand type safety as a practice, not just a syntax.

  5. "never isn't just a curiosity — it's the key to exhaustive checking" — demonstrates you use the type system to prevent runtime bugs.

  6. "I enable noUncheckedIndexedAccess and exactOptionalPropertyTypes in every project" — shows you push TypeScript's strictness beyond the defaults.