Skip to content
yutils

How TypeScript Types Actually Work

Structural typing, narrowing, discriminated unions, generics, conditional / mapped types — what TypeScript actually checks at compile time, why runtime values can still surprise you, and the patterns that make the type system pull its weight.

~9 min read

TypeScript promises "type safety," but at runtime all type information is erased. How does typeof foo === 'object' confirm the actual shape? Why do two interfaces feel interchangeable? Why are conditional / mapped types so powerful? This guide walks through structural typing, narrowing, generics, and the runtime gap you still have to close.

Structural typing — duck typing on steroids

Unlike Java/C#'s nominal typing, TypeScript matches by shape:

interface Point2D { x: number; y: number; }
interface Vector2D { x: number; y: number; }

const p: Point2D = {x: 1, y: 2};
const v: Vector2D = p;  // ✓ compatible (same shape)

function distance(p: Point2D): number {
  return Math.sqrt(p.x ** 2 + p.y ** 2);
}

distance({x: 3, y: 4, z: 5});  // ✓ z is fine (not excess-property case)
distance(v);                    // ✓ Vector2D works

Walks like a duck = it's a duck. Function arguments work the same way — having the required fields is enough.

Branded types — nominal-ish

type UserId = string & {__brand: 'UserId'};
type PostId = string & {__brand: 'PostId'};

function getUser(id: UserId) { /* ... */ }

const a: UserId = '42' as UserId;
const b: PostId = '99' as PostId;

getUser(a);  // ✓
getUser(b);  // ✗ Type 'PostId' is not assignable to 'UserId'

Both are string, but the brand differentiates. The as UserId escape hatch means it's a convention, not a hard wall.

Type narrowing — control flow analysis

function process(value: string | number) {
  if (typeof value === 'string') {
    value.toUpperCase();  // ✓ narrowed to string
  } else {
    value.toFixed(2);     // ✓ narrowed to number
  }
}

TypeScript tracks control flow. Narrowing forms:

  • typeof value === 'string'
  • value instanceof Date
  • 'name' in object
  • value === null / value !== null
  • Discriminated unions (next)
  • User-defined type guards (function isUser(x): x is User)

Discriminated unions — the strongest pattern

type Shape =
  | {kind: 'circle'; radius: number}
  | {kind: 'square'; size: number}
  | {kind: 'rectangle'; width: number; height: number};

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
      // ↑ shape narrowed to {kind: 'circle', radius: number}
    case 'square':
      return shape.size ** 2;
    case 'rectangle':
      return shape.width * shape.height;
  }
}

A discriminator field (kind) distinguishes union members. TypeScript narrows in each case automatically.

Exhaustiveness check

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return /* ... */;
    case 'square': return /* ... */;
    // forgot 'rectangle'
    default:
      const _exhaustive: never = shape;  // ✗ compile error
      return _exhaustive;
  }
}

// Add a new Shape member → default's never assignment fires → forces handling.

never means "unreachable." Adding a new case forces the compiler to flag the gap.

Generics — functions over types

function identity<T>(value: T): T {
  return value;
}

identity<string>('hello');   // T = string
identity(42);                // T = number (inferred)

function head<T>(arr: T[]): T | undefined {
  return arr[0];
}

head([1, 2, 3]);             // number | undefined
head(['a', 'b']);            // string | undefined

Generic constraints:

function longest<T extends {length: number}>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest('hello', 'world');                    // ✓ string has length
longest([1, 2, 3], [4, 5]);                   // ✓ array has length
longest(42, 100);                             // ✗ number has no length

Conditional types — if/else at the type level

type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false

// Type-level filtering
type NonNullable<T> = T extends null | undefined ? never : T;
type NN1 = NonNullable<string | null>;  // string
type NN2 = NonNullable<number | undefined | null>;  // number

// Built-in utility types are built on conditionals
type ReturnType<F> = F extends (...args: any) => infer R ? R : never;
type R = ReturnType<() => string>;  // string

The infer keyword extracts types from positions. Powerful for libraries; use sparingly in application code.

Mapped types — map over fields

type User = {
  id: string;
  name: string;
  email: string;
};

type Optional<T> = {
  [K in keyof T]?: T[K];   // all fields optional
};

type PartialUser = Optional<User>;
// = {id?: string; name?: string; email?: string}

type ReadOnly<T> = {
  readonly [K in keyof T]: T[K];
};

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type UserPreview = Pick<User, 'id' | 'name'>;
// = {id: string; name: string}

Partial, Required, Readonly, Pick, Omit, Record all ride on mapped types. The library backbone of TypeScript.

Runtime gap — types disappear

interface User { id: string; name: string; }

function isUser(x: unknown): x is User {
  return (
    typeof x === 'object' &&
    x !== null &&
    'id' in x &&
    'name' in x
  );
  // ↑ doesn't verify the field types. id = 42 (number) still passes.
}

// Safer — use Zod-style runtime validation
import {z} from 'zod';

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
});

type User = z.infer<typeof UserSchema>;  // TS type generated automatically

const validated: User = UserSchema.parse(unknownInput);
// throws if invalid

TS types are compile-time only. API responses, JSON.parse, user input — all need runtime validation. Zod / Yup / io-ts.

JSON → TypeScript automatic conversion

Generating interfaces from an API response:

// JSON
{
  "name": "Alice",
  "age": 30,
  "posts": [{"id": "1", "title": "Hello"}]
}

// Auto-generated TypeScript:
interface RootObject {
  name: string;
  age: number;
  posts: Post[];
}
interface Post {
  id: string;
  title: string;
}

JSON → TypeScript does exactly this. Nested objects become separate interfaces. Great for starting API exploration.

any vs unknown vs never

  • any — disables type checking. Anything goes. You give up type safety.
  • unknown — "I don't know yet." Can't be used until narrowed.
  • never — "this cannot happen." Used in exhaustiveness checks and never-returning functions.
const a: any = 'hello';
a.toUpperCase();        // ✓
a.foo.bar.baz;          // ✓ (runtime crash)
a.nonExistent();        // ✓ (runtime crash)

const b: unknown = 'hello';
b.toUpperCase();        // ✗ Object is of type 'unknown'
if (typeof b === 'string') b.toUpperCase();  // ✓ narrowed

function fail(msg: string): never {
  throw new Error(msg);
}

Prefer unknown over any. unknown forces narrowing explicitly.

Common pitfalls

1. any infects everything

const data: any = JSON.parse(rawString);
const name = data.user.name;  // any
const id = name.length;        // any
// → type safety collapses

// Better
const data: unknown = JSON.parse(rawString);
const validated = UserSchema.parse(data);  // Zod

2. as casting

const x = JSON.parse(input) as User;
// No runtime check. Garbage JSON → mysterious runtime crash.

// Better
const x = UserSchema.parse(JSON.parse(input));

3. ! (non-null assertion) overuse

const el = document.querySelector('.foo')!;  // ignores null
el.click();  // possible runtime crash

// Better
const el = document.querySelector('.foo');
if (!el) return;
el.click();

4. type vs runtime imports

import {User} from './types';   // works

// Explicit — bundlers can drop it as dead code
import type {User} from './types';

// Or
import {type User, isUser} from './types';
// 'type' keyword marks User as type-only

5. Excess property check escape

interface User { name: string; }

const u: User = {name: 'Alice', age: 30};  // ✗ excess 'age'

// But via a variable, it slips through
const obj = {name: 'Alice', age: 30};
const u2: User = obj;  // ✓ (structural)

// Weak protection — use Zod's .strict() for runtime

References

Summary

  • TypeScript uses structural typing — shape match. Different from Java/C# nominal.
  • Narrowing via typeof / instanceof / in / discriminated unions / type guards.
  • Discriminated unions + exhaustiveness check via never is the strongest pattern.
  • Generics = functions over types, with extends constraints.
  • Conditional and mapped types underpin Partial / Pick / Record. Library territory.
  • Types are compile-time only — validate with Zod / Yup at the boundary.
  • Prefer unknown to any; avoid as casts and ! assertions.
  • Try it — JSON → TypeScript auto- generates interfaces from JSON. JS Object → JSON converts JS object literals into JSON.
Back to guides