TypeScript: Fundamentals
typescript, structural typing, type inference, type narrowing, generics, discriminated unions, type guards, tsconfig, strict mode
Introduction
The bug report was three words long: “checkout is broken.” The stack trace was one line: TypeError: order.total.toFixed is not a function. It had fired in production, during a real purchase, on a code path that passed every unit test and every end-to-end test in CI. total was a number; it had always been a number; toFixed is a method every number has. Except this time total was the string "49.99", because a week earlier someone had changed the pricing service to return totals as strings, and nothing between that service and the checkout component noticed. The value flowed through four functions, each happily passing it along, until it reached the one place that called a number-only method on it. The code was not wrong about what to do with a total. It was wrong about what a total was, and JavaScript had no opinion on the subject until the exact line where it crashed.
The instinct after an incident like this is to write a test that builds an order with a string total and asserts the checkout fails gracefully. Worth doing, but it’s the wrong lesson. A test catches the one shape you thought to check; the failure here was a shape nobody thought to check, which slipped in when an upstream contract changed. You cannot test your way to confidence about every value’s shape — there are too many. What you can do is move the question — “is this actually a number?” — out of runtime, where it ambushes you in production, and into compile time, where it greets you in your editor before you’ve saved the file. That is the entire pitch for TypeScript: it is a type layer over JavaScript that catches a whole class of bug — the wrong-shaped value, the missing field, the method called on undefined — before the program runs.
The Core Insight
JavaScript is dynamically typed, which is liberating until it isn’t. A variable can hold a number now and a string later; an object can sprout or shed properties at will; a function takes arguments of any shape and only complains when it tries to use one in a way that doesn’t work. This flexibility is useful for small scripts and catastrophic at scale, because it pushes every “is this the right shape?” question to the latest possible moment — the moment the code runs the operation that assumes the shape. By then you are in production, and the answer is a crash.
TypeScript’s insight is to answer those questions earlier, with three properties that together define the whole language. First, it is structural: a type describes the shape of a value — which properties it has, of what types — and two values are compatible if their shapes are, regardless of what their types are named. This is duck typing made checkable: if it has a name: string and a total: number, it counts as an order, whoever declared it. Second, it is gradually typed: you don’t type everything at once but add types file by file, leaving escape hatches where you’re not ready and tightening over time — adoption is a dial, not a switch. Third, and most importantly, types are fully erased: the compiler checks your types and then deletes them, emitting plain JavaScript with no type information left in it. The types are a proof you ran at build time, not a structure that exists at run time. The compiler is a proof assistant for shapes — it verifies they line up — but leaves nothing behind to enforce them once the program is running.
That third property surprises people, and it has sharp consequences. Because types vanish, they cost nothing at runtime: a TypeScript program is exactly as fast as the JavaScript it compiles to. But for the same reason, types cannot guard data the compiler never saw — a JSON blob off the network, a value read from localStorage, anything that enters from the outside at runtime. The compiler proved your code is internally consistent; it did not, and could not, prove the world handed you what you claimed.
A mental model
Think of TypeScript as a spell-checker for data shapes that runs before the program does. A spell-checker doesn’t change what your document means; it sits alongside the text and underlines the places where a word matches nothing it knows, so you fix them before publishing. TypeScript does the same for values: it reads your code without running it, traces how every value flows from function to function, and underlines the places where a value’s shape doesn’t match what the next operation expects. When you ship, the underlines — the types — are gone, exactly as the red squiggles never appear in the printed page. The text that ships is plain JavaScript.
The second half of the model is structural, and the contrast is with named typing. In Java or C#, a value is a Point only if some code declared it to be a Point; the name is part of its identity. TypeScript doesn’t care about the name. A type is a contract about shape — “anything with an x: number and a y: number” — and any value satisfying the shape satisfies the contract, no declaration required. Two types declared independently with identical fields are interchangeable. This is why you write far fewer annotations than you’d expect: the compiler matches shapes, and most shapes it infers from the values themselves.
When to lean on the type system
TypeScript is gradual, so the real question is rarely “TypeScript or not?” but “how much, and where?” Figure 13.1 shows the pipeline that makes the trade-offs concrete: source is checked against structural shapes, then erased to plain JS. Knowing types are compile-time-only is what tells you where they earn their keep and where they can’t help.
Lean hard on types for anything with a long life or many hands — a codebase several people edit, a library others consume, a system that must survive a refactor a year from now. These are where the compiler pays for itself, because a type is a contract enforced across every call site at once. Rename a field, change a return shape, and the compiler immediately lists every place that now disagrees. This is refactoring with a safety net, and it is the single biggest reason teams adopt TypeScript.
The payoff is concentrated in strict mode. TypeScript with strict off is a much weaker tool — it tolerates implicit any, ignores null and undefined, and quietly lets through most of the bugs you adopted it to catch. The settings that matter (below) are mostly the ones strict turns on. Skip it and you’ve paid the build-step tax for a fraction of the benefit.
Know where types stop helping. Because they’re erased, types do nothing for data crossing the boundary at runtime — API responses, parsed JSON, form input, environment variables. The compiler will let you assert a JSON blob is a User, but that assertion is a promise you make to the compiler, not a check it performs. For internal code, types are nearly free safety; for untrusted external data you still need runtime validation, and the any escape hatch — useful for migration — is a hole in the net you should grep for and close.
What you’ll learn
- How structural typing decides compatibility by shape rather than by name, and why that makes duck typing safe instead of dangerous
- Why type inference means you write far fewer annotations than you’d expect, and where an explicit annotation still earns its place
- How control flow narrows types — how
typeof,in, and discriminated unions let the compiler refine a broad type to a precise one as it reads your conditionals - How generics let you write reusable code that preserves type information instead of discarding it to
any - How discriminated unions model “one of N states” so that handling all of them becomes something the compiler can check exhaustively
- Why types are erased at compile time — and the concrete things that follow, like why you can’t
instanceofan interface and must validate external data by hand - Which
tsconfigsettings actually matter, and whystrictis the one that turns TypeScript into the tool you adopted it to be
Prerequisites
- JavaScript fundamentals: functions, objects, arrays, classes, and
async/await; thenull/undefineddistinction; how methods are looked up on values (the JavaScript Basics material) - ES6+ syntax: destructuring, spread, arrow functions, and modules — TypeScript is a superset, so all of this carries over unchanged
- Comfort at a shell: running
npm, installing a dev dependency, invoking a CLI
Structural typing and inference
Start with the property that defines the language, because everything else follows from it. In TypeScript a type is a description of shape, and compatibility is decided by comparing shapes — not by checking whether one type was declared to be another. The classic demonstration is two independently declared types with the same fields:
type Point = { x: number; y: number };
type Coord = { x: number; y: number }; // declared separately, never linked
const p: Point = { x: 1, y: 2 };
const c: Coord = p; // OK — same shape, so interchangeableIn a nominally typed language this last line would be an error: p is a Point, not a Coord, and the two were never declared to relate. TypeScript accepts it without comment, because it doesn’t see two names — it sees two shapes, and they’re identical. The practical version: a function declares the shape it needs, and any value meeting that shape is welcome, however it was built:
// greet asks for "anything with a name string" — not for a specific named type.
function greet(who: { name: string }): string {
return `hi ${who.name}`;
}
const user = { id: 7, name: "Ada", role: "admin" };
greet(user); // OK — user has a name; the extra fields are irrelevant hereThis is duck typing — “if it has a name, treat it as something with a name” — but unlike JavaScript’s runtime duck typing, the compiler verifies the duck before the program runs. The contract is the shape the function asks for, and the compiler checks every caller against it.
The companion to structural typing is inference, the reason TypeScript feels lighter to write than its reputation suggests. You do not annotate everything; the compiler deduces types from the values you assign. let count = 0 is a number because 0 is; const config = { host: "localhost", port: 5432 } is inferred as { host: string; port: number } with no annotation at all. The rule of thumb: let inference do the obvious work and reserve explicit annotations for what it can’t see — function parameters (the compiler can’t guess what you’ll be handed) and the boundaries of your code, where you want to state the contract on purpose. Annotating a local whose type is plain from its initializer — const x: number = 5 — is just noise.
One inference subtlety bites everyone once. The compiler widens literals to their general type: method: "GET" in a mutable object is inferred as string, not the literal "GET", because the field could be reassigned. When you need the narrow literal — to satisfy a function expecting "GET" | "POST" — a const assertion freezes it:
const req = { url: "/api", method: "GET" } as const; // method is "GET", not string
function send(m: "GET" | "POST") { /* ... */ }
send(req.method); // OK only because of `as const`Type narrowing and guards
A structural type is often deliberately broad — string | number, User | null, or the unknown you get from parsing untrusted data. Broad types are honest (the value could be any of these) but you can’t use them until you know which one you’re holding. Narrowing is how the compiler refines a broad type to a precise one by reading your control flow: inside the branch where you’ve checked, it knows more than it did outside.
The everyday tools are JavaScript operators the compiler understands. typeof narrows primitives; the in operator narrows by the presence of a property; instanceof narrows by class. After the check, no cast is needed — the compiler has already updated its understanding:
function describe(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase(); // here, value is string — .toUpperCase() is safe
}
return value.toFixed(2); // here, value can only be number
}Writing (value as string).toUpperCase() inside that first branch is a common reflex and a mistake: the assertion is redundant, since narrowing already proved it, and sprinkling assertions teaches you to reach for the one tool that silences the compiler instead of satisfying it. When you find yourself casting, ask “why doesn’t narrowing already know this?” — the answer usually points at a better-shaped type.
For shapes the built-in operators can’t express, you write a type guard: a function whose return type is the special value is T predicate. The compiler treats a true return as proof that the argument has type T, and narrows accordingly at the call site. This is the idiomatic way to teach the compiler about a runtime check — and the right place to validate external data, since the guard runs at runtime where the data is:
type User = { id: string; name: string };
// The `obj is User` return type is what makes this a narrowing guard.
function isUser(obj: unknown): obj is User {
return (
typeof obj === "object" && obj !== null &&
typeof (obj as Record<string, unknown>).id === "string" &&
typeof (obj as Record<string, unknown>).name === "string"
);
}This bridges the gap erasure creates: the guard is real runtime code that inspects the value, and its is User signature is the compile-time promise the compiler relies on once the runtime check passes.
Generics: reusable code that keeps its types
Without generics, “code that works for any type” means any, and any throws away exactly the information you adopted TypeScript to keep. A function typed (arg: any) => any accepts everything and tells the caller nothing about what comes back — the type dies on entry. Generics are the fix: a type parameter lets a function or class be written once and used at many types while preserving the relationship between what goes in and what comes out.
The canonical example is the identity function, trivial in behavior but exact in typing:
function identity<T>(arg: T): T { // T is a type variable, bound at each call
return arg;
}
const s = identity("hello"); // T inferred as string — s is string, not any
const n = identity(42); // T inferred as number — n is numberThe <T> declares a placeholder type; (arg: T): T says “whatever comes in, the same comes out.” The compiler infers T per call from the argument, so you rarely write it explicitly. Generics shine in containers and wrappers — anything that holds or transforms values of a type it shouldn’t need to know in advance. A typed API-response wrapper is the example you’ll reach for constantly:
type ApiResponse<T> = { data: T; status: number };
async function fetchJson<T>(path: string): Promise<ApiResponse<T>> {
const res = await fetch(path);
return { data: (await res.json()) as T, status: res.status };
}
const r = await fetchJson<User>("/me"); // r.data is typed as User end-to-endGenerics can be constrained with extends, restricting the placeholder to types with a particular shape so the function can rely on that shape while still accepting anything that meets it — <T extends { length: number }> lets a function touch .length safely, accepting strings and arrays but rejecting a bare number.
Note the as T cast in fetchJson — erasure showing its hand. The generic gives you a typed data, but the cast is unchecked at runtime: nothing verifies the JSON actually matches T. The types are a promise about what should come back; the validation, if you need it, is still yours to write. We return to this in the exercise.
Discriminated unions: modeling one of N states
A great deal of real code is “this value is in exactly one of several states, each carrying different data.” A network request is loading, succeeded with data, or failed with an error — never two at once, and the fields that make sense depend on which. Model this with optional fields (data?, error?) and you get a type that lies: it admits a “succeeded with an error and no data” value that should be impossible, and every reader must defensively check fields that may or may not be there.
The idiomatic alternative is a discriminated union: a union of object types that share one common literal-typed field — the discriminant — whose value identifies the variant. Each variant carries exactly the fields that make sense for it, and the impossible combinations become unrepresentable:
type RequestState<T> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };Switching on the discriminant narrows the whole object: inside case "success" the compiler knows data exists; inside case "error" it knows message exists and data does not. And a never-typed default gives exhaustiveness checking for free — the compiler errors if a future variant goes unhandled, because the unhandled case would reach the default with a type that isn’t never:
function render<T>(s: RequestState<T>): string {
switch (s.status) {
case "loading": return "...";
case "success": return `got ${JSON.stringify(s.data)}`; // s.data is T here
case "error": return `failed: ${s.message}`; // s.message exists here
default:
const _exhaustive: never = s; // compile error if a variant is unhandled
return _exhaustive;
}
}Add a fourth state and forget to handle it, and the assignment to _exhaustive stops compiling, pointing straight at the gap. This turns “did I handle every case?” from a code-review question into a compiler guarantee — moving a runtime crash to compile time, applied to state handling.
The compile-and-erase model
Everything above rests on one mechanism, worth stating plainly. The TypeScript compiler, tsc, does two separate jobs in sequence. First it type-checks: it reads your source, builds up the type of every value, and verifies every operation is consistent with the shapes involved — reporting mismatches as errors. Then it emits: it strips out every type annotation, interface, and alias, and writes plain JavaScript. The two jobs are independent; tsc will emit JavaScript even when type-checking found errors, unless you tell it not to, because the output is just your code with the types peeled off.
Figure 13.1 traces this: source carrying both values and type annotations flows into the checker, the checker verifies shapes are compatible, and erasure then removes the types entirely before the plain JavaScript runs on an engine that never heard of them.
The consequences are concrete. You cannot instanceof an interface — if (x instanceof User) is a compile error when User is an interface or alias, because instanceof is a runtime operator and there is no User at runtime to check against; the name was erased. You cannot dispatch on a type at runtime, which is exactly why discriminated unions carry an explicit status value — the value survives erasure, the type does not. And you cannot trust a type assertion on external data: JSON.parse(blob) as User compiles cleanly and checks nothing, so if the blob isn’t a User, the lie sails through to the first line that relies on it — precisely the checkout crash from the introduction, now with a name. The type system protects your code’s internal consistency; the data crossing its edges is your responsibility, with runtime validation, because the compiler cannot guard what isn’t there when it runs.
any that quietly switched the type system off
A team integrating a third-party analytics SDK hit a wall of red squiggles — the library’s types were incomplete, and strict mode rejected half the calls. Under deadline, someone typed the client as any “just to ship,” with a // TODO: type this. The trouble with any is that it is contagious: every value derived from an any is itself any, so the untyped-ness spread outward from that one client through every function that touched its results, switching off the compiler across a whole module without a single visible error. Months later a field was renamed in the SDK; the rename should have lit up every call site in red, but those sites were all downstream of the any, so the compiler said nothing. The typo flowed to production and surfaced as undefined in a billing report. The fix is two rules that travel together: type external integrations as unknown, not any, forcing a narrowing check before use; and treat any as a tracked debt you grep for, not a quiet convenience — one any doesn’t weaken the type system locally, it punches a hole that propagates as far as its values flow.
tsconfig and strict: the settings that matter
tsconfig.json configures the compiler, and it has dozens of options, but a short list does the real work. The single most important is "strict": true — not one check but a bundle, and turning it on is most of the difference between TypeScript that catches bugs and TypeScript that mostly autocompletes. Two of its members carry the weight. noImplicitAny forbids the compiler from silently falling back to any when it can’t infer a type — it makes you say any out loud if you mean it, which is how any stays greppable rather than ambient. strictNullChecks prevents the most crashes: with it off, null and undefined are assignable to everything, so a function typed to return User may return null and the compiler shrugs; with it on, they are their own types you must handle explicitly, which is what makes user?.name ?? "guest" a checked pattern rather than a hopeful one.
Beyond strict, a few options are worth knowing by name: noUncheckedIndexedAccess makes array and record access return T | undefined (the index might be out of bounds — closer to the truth), and target/module/esModuleInterop govern the JavaScript version and module system you emit. The governing principle: turn strict on from day one. Enabling it on a mature codebase means confronting thousands of latent errors at once — every unhandled null, every implicit any, surfacing together — which is why teams who skip it at the start so often never adopt it. Starting strict is nearly free; retrofitting it is a project.
Build it → TypeScript in production across the portfolio: the typed React frontend of Project 05: SaaS Web Platform consumes a typed API contract end-to-end (the
ApiResponse<T>and discriminated-union patterns above, at scale), and the TypeScript client of Project 16: CRDT Collaboration models conflict-free replicated data types whose operation messages are exactly the kind of “one of N states” that discriminated unions exist for.
Practical exercise
Difficulty: Level I · Level II · Level III
Level I — Add types and
strict, fix what surfaces. Rename a small JavaScript module to.tsand add atsconfig.jsonwith"strict": true. Work through every error the compiler now reports — mostly implicitanyparameters and unhandlednull/undefined— fixing them with parameter annotations and?./??rather than asserting them away. Note how many errorsstrictsurfaced; each is a bug that previously could only have appeared at runtime.Level II — Model a state machine as a discriminated union, handle it exhaustively. Pick a small machine — a checkout flow (
cart → shipping → payment → confirmed) or a media player (idle / playing / paused / ended). Model each state as a union member with a sharedstatusdiscriminant carrying only the fields that make sense for it. Writetransition(state, event)andrender(state)functions thatswitchon the discriminant, each with anever-typed default. Then add a new state and watch the compiler point at every place that now fails to handle it — exhaustiveness checking doing your code review.Level III — Design a generic, type-safe API client, and find erasure’s edge. Build a
request<T>(path): Promise<T>wrapper that gives callers a fully typed result, with endpoint definitions carrying their request and response types. Then write the paragraph that matters: explain precisely where the type safety ends. Identify every point where a value enters typed without being checked — theas Ton the parsed JSON, anas constendpoint map — and explain why, because types are erased, the compiler verified none of it at runtime. Show where you’d insert runtime validation (a type guard, or a schema validator like Zod) to close the gap, and state the rule: types secure the inside of your program, runtime validation secures its boundary.
Summary
TypeScript catches a class of bug no amount of testing reliably catches — the wrong-shaped value, the missing field, the method called on undefined — by moving the “is this the right shape?” question from runtime, where it crashes production, to compile time, where it greets you in the editor. It does this with three defining properties. It is structural: compatibility is decided by shape, not name, which makes duck typing safe and lets inference deduce most of your types so you annotate far less than you’d expect. It is gradual: you adopt it file by file, with escape hatches, tightening as you go. And it is erased: the compiler checks your types and deletes them, emitting plain JavaScript — so types cost nothing at runtime but cannot guard data the compiler never saw. The vocabulary on top is narrowing (control flow refining a broad type to a precise one), generics (reuse that preserves type information), and discriminated unions (one of N states with compiler-checked exhaustiveness) — all switched fully on by strict mode, where the safety actually lives.
Key takeaways
- A type describes a shape, and compatibility is structural — two types with the same fields are interchangeable regardless of their names; this is what makes duck typing checkable.
- Inference does most of the work: annotate function parameters and boundaries, let the compiler infer the rest, and reach for
as constwhen you need a literal kept narrow. - Narrowing refines types through control flow (
typeof,in, customx is Tguards) — prefer narrowing over assertions, which silence the compiler instead of satisfying it. - Generics preserve the input/output type relationship that
anythrows away; constrain them withextendswhen the code needs a particular shape. - Discriminated unions plus a
neverdefault give exhaustiveness checking — a compiler guarantee that every state is handled. - Types are erased at runtime: you can’t
instanceofan interface, and you must validate external data by hand, because the compiler protects your code’s internals, not its boundary. Turnstricton from day one; retrofitting it later is a project.
Connections to other chapters
- The Polyglot Landscape (sibling): TypeScript sits at the high-abstraction, structurally-typed corner of the language space — a useful contrast to Python’s optional, largely nominal typing and to Rust’s nominal traits with explicit bounds. The same problem (let code work across types safely) gets a structural answer here and a trait-based one there.
- Type Systems and Generics (extension): the structural, erased model here is the foundation for type-level programming — mapped types, conditional types,
infer, and template literals are all operations on the shapes this chapter introduced, and only make sense once structural compatibility is second nature. That chapter sets TypeScript’s approach beside the way Java, C++, and Rust solve the same “code across types” problem. - Concurrency and Parallelism Models (extension): the generics and narrowing here are the base for typing asynchronous code, where
Promise<T>carries the result type throughawaitand discriminated unions model the loading/success/error states async flows produce — and that chapter compares TypeScript’s promise-and-event-loop model against the threads, goroutines, and async runtimes of the other languages. - Python: Advanced Language Features (sibling): Python’s
Protocolis structural typing arriving in a historically nominal language — the direct analogue of an interface here. Comparing pervasive structural checking with Python’s opt-inProtocolshows two ecosystems converging on the same idea from opposite starting points.
Further reading
Essential
- The TypeScript Handbook (typescriptlang.org) — the canonical reference; the “Everyday Types,” “Narrowing,” and “Generics” sections map directly onto this chapter.
- Vanderkam, Effective TypeScript — sixty-odd specific, pragmatic items on using the type system well in real codebases; the best second book after the Handbook.
Deep dives
- Cherny, Programming TypeScript — a systematic build-up from the type system’s foundations to advanced type-level programming, strong on why the rules are what they are.
- The TypeScript Playground (typescriptlang.org/play) — not a text but the fastest way to see erasure: write typed source on the left, watch the emitted, type-stripped JavaScript appear on the right.
Historical context
- Pierce, Types and Programming Languages — the standard text on type systems; structural vs. nominal typing and the meaning of soundness come from this tradition.
- Siek and Taha, “Gradual Typing for Functional Languages” (2006) — the paper that formalized gradual typing, the property that lets TypeScript coexist with untyped JavaScript and be adopted incrementally.