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 worksWalks 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 objectvalue === 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 | undefinedGeneric 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 lengthConditional 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>; // stringThe 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 invalidTS 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); // Zod2. 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-only5. 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 runtimeReferences
- TypeScript Handbook — typescriptlang.org
- Type Challenges — GitHub
- Zod — zod.dev
- Effective TypeScript (Dan Vanderkam) — effectivetypescript.com
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
neveris the strongest pattern. - Generics = functions over types, with
extendsconstraints. - Conditional and mapped types underpin Partial / Pick / Record. Library territory.
- Types are compile-time only — validate with Zod / Yup at the boundary.
- Prefer
unknowntoany; avoidascasts and!assertions. - Try it — JSON → TypeScript auto- generates interfaces from JSON. JS Object → JSON converts JS object literals into JSON.