CHAPTER 04 · LANGUAGE

The TypeScript Type System

Coming from Java or C#, TS types look familiar, but the underlying philosophy differs: they describe the shape of data, not class lineage; and they exist only while you write code, leaving no trace at runtime. Grasp those two points and you know what types can and cannot protect you from.

⏱ ~60 minDeepUtility types are Use
By the end of this chapter you should be able to:
  • Explain structural typing vs Java's nominal typing, and predict whether an assignment is legal.
  • Model multi-state data with a discriminated union and write an exhaustiveness check.
  • Explain why API responses, forms and LLM output must be validated at runtime, and use Zod so types and validation share one source.
  • Spot dangerous "escape hatches" in review: any, as, !, @ts-ignore.

Opening: all green, still crashed in production

Opening question

LinkNote passes tsc with zero errors. One day production reports TypeError: Cannot read properties of undefined (reading 'length') at link.tags.length. The Link type clearly says tags: string[] — it can't be undefined. Why didn't the type system catch it?

Where the answer lies: that record came from an external API, and the code says const link = await res.json() as Link. as means "compiler, trust me". The compiler did — but the actual runtime data had no tags field. TS types are your declaration of the data's shape. The compiler checks your code against the declaration; it never checks real data against the declaration. So every piece of data entering from outside the program must be validated once at runtime.

Structural typing: shape, not ancestry

In Java, compatibility depends on declarations: class Dog implements Animal lets a Dog be used as an Animal. That's nominal typing. TypeScript uses structural typing: if the shape fits, it's compatible, no declaration needed.

It's a lot like Go's interfaces (implicit implementation), just extended to every type, plain objects included.

A side effect of structural typing

Two types with the same shape are interchangeable even if they mean completely different things: type UserId = string and type LinkId = string are the same thing to the compiler, so passing a user ID where a link ID is expected won't error. When you need to distinguish them, use the "branded type" trick: type LinkId = string & { __brand: "LinkId" }. Aware

Building blocks

SyntaxMeaning
string, number, booleanPrimitives (JS has one number type: 64-bit float)
string[] or Array<string>Array
[string, number]Tuple: fixed length, fixed type per slot
{ url: string; title?: string }Object type; ? means optional (may be undefined)
readonly string[]Read-only (compile-time only)
"pending" | "done"Union of literal types: only one of these two strings
string | nullUnion: a string, or null
A & BIntersection: has all fields of both A and B
unknown"I don't know what this is"; must be narrowed before use — safe
any"Turn off type checking" — dangerous, spreads like a virus
neverA value that can't exist (used for exhaustiveness checks)

type or interface? For describing object shapes they're almost interchangeable. interface can be re-declared and merged automatically and sometimes gives friendlier errors; type can express anything, including unions and tuples. In practice: pick one as a team convention. Many projects default to type and use interface for public object shapes meant to be extended. It's style, not correctness.

Unions and narrowing Deep

Union types are TS's most expressive feature and where it differs most from Java's type system. If a value's type is string | null, you can't call .length directly — you must first prove it isn't null. That proof is called narrowing: the compiler follows your if, return, typeof, in checks and gives the same variable different types at different points in the code.

Discriminated unions: modeling multi-state data

Each LinkNote bookmark has a status: pending, done or failed. Each status has different fields: only done has a summary, only failed has an error. A Java programmer's first instinct is usually one big class with every field nullable:

The discriminated-union approach: one shape per state, told apart by a shared literal field (the discriminant). Illegal states simply can't be represented:

This pattern is everywhere in TS: request state (loading / success / error), LLM streaming message parts (text / tool-call / tool-result), Redux actions, event types… Whenever you see a type: "..." or kind: "..." field, think of it.

Exhaustiveness checks: let the compiler tell you what you missed

If someone later adds a fourth status archived to Bookmark, every switch that forgot it fails to compile. This is the type system at its most valuable: turning "changed it here, forgot it there" bugs into compile errors. When agents make sweeping changes, that protection matters more than ever.

Type inference: write fewer types, in the right places

TS inference is strong. Most local variables need no annotation. A practical rule:

Two keywords that help inference:

The typeof STATUSES line shows an important habit: derive types from a single source of truth instead of writing them by hand in two places that can drift apart.

Generics

Generics let a function or type stay general over "some type to be decided" while keeping type information. Same concept as Java generics, but in TS they're used heavily for inference:

A very common "generic lie"

This is the same thing as the opening's as Link: a type parameter is just your declaration. Agents love writing functions like this. The right approach is to pass a validator in (next section).

Utility types: deriving new types from existing ones Use

UtilityEffectLinkNote example
Partial<T>Make every field optionalBody of an update endpoint
Pick<T, K> / Omit<T, K>Keep / drop some fieldsOmit<Link, "id" | "createdAt"> as create input
Record<K, V>Object with keys K and values VRecord<Status, string> status → label
ReturnType<F> / Awaited<P>A function's return type / unwrap a PromiseDerive data types from query functions
keyof T / T[K]Union of keys / type of a fieldLink["tags"]

You'll see much fancier "type gymnastics" in library declarations (conditional, mapped and template literal types). Aware Knowing they exist is enough: reading the errors matters more than writing them. Hand complex type-writing to the agent, but require type tests or usage examples.

Type erasure and boundary validation Deep

Back to the opening question. Since types don't exist at runtime, data in your program falls into two categories:

Outside world (types untrusted) HTTP bodies / URL params Third-party API responses LLM output (structured JSON) Forms / localStorage Env vars / DB JSON columns schema.parse() runtime validation fail → clear error pass → typed value Program interior (types trusted) business logic, components, utils only handle validated data can rely on tsc no defensive checks scattered inside Rule: validate once at the boundary, trust types inside. Not the reverse — defensive checks everywhere inside, `as` at the boundary.
The trust boundary. "Outside" means any data not produced by the current TypeScript compilation.

Zod: define once, get both a validator and a type

Zod is the de facto standard for runtime validation in TS (Valibot and ArkType are alternatives; most implement the shared Standard Schema interface, so frameworks accept them generically). Its core value: you write one schema and the TypeScript type is derived from it, so the two can never disagree.

You'll see the same Zod schema reused in many places: form validation, API input validation, env var validation, DB result validation — and, very importantly, constraining LLM structured output (Chapter 11).

Escape hatches: when they're OK and when they're red flags

SyntaxMeaningReview guidance
anyTurns off all checks on the value, and infects everything that touches it🚩 Almost always should become unknown + narrowing or validation
x as TType assertion: "trust me, it's T"🚩 Especially dangerous on external data; acceptable in a few cases like DOM elements
x!Non-null assertion: "trust me, it's not null"⚠️ Ask why it can't be null; usually replaceable with an explicit check
// @ts-ignoreIgnore any error on the next line🚩 Not allowed; if truly needed, use @ts-expect-error with a comment explaining why
as unknown as TDouble assertion, force any type into any other🚩 Basically admitting the model is wrong
Let machines enforce the review rules

These rules can be enforced by lint (typescript-eslint's no-explicit-any, no-non-null-assertion; Biome has equivalents) and written into AGENTS.md. Humans and agents both take shortcuts "to make the error go away"; machine checks don't go soft.

About enum

People from Java/C# naturally reach for enum. In modern TS, prefer unions of string literals or as const objects: they're pure types that erase cleanly (Node's type stripping doesn't support enum, because enums generate runtime code), and they map naturally onto string values in JSON and databases.

Reading type errors

TS errors can run to dozens of lines. Three tips:

  1. Read from the bottom up. The last line is usually the most specific mismatch ("property X is missing in type Y").
  2. Hover both sides. Look at the "expected" type and the "actual" type separately; the difference is the answer.
  3. Ask the agent to explain, not to fix. First ask "what inconsistency in my data model does this error reveal?"; once you understand, decide whether to change the type or the code. Saying "fix this error" often gets you an as.
Tell the agent
Vague
Precise
"Add a status field to bookmarks."
"Model Bookmark as a discriminated union on status: pending (url), done (url, summary, tags), failed (url, error). Define it as a Zod schema and export the type via z.infer. Add exhaustiveness checks to every switch. Don't use optional fields for state-dependent data."
"Fix these type errors."
"Fix the type errors under these constraints: no any, no as, no non-null assertions, no @ts-ignore. If an error means the data model itself is wrong, stop and explain it to me before proposing a change."
Interview corner
  • any vs unknown? Both accept anything. any turns checking off and can be used freely; unknown must be narrowed before use. External data should be unknown.
  • What's structural typing, and how does it differ from Java? Compatibility by shape, not declared relationships; give an example of a class assignable to an interface it never declared.
  • Why does a TS project still need Zod? Type erasure: no type information at runtime, so external data must be validated at the boundary. Zod makes the schema the single source of truth with types derived from it.
  • What's a discriminated union and what problem does it solve? A shared literal field distinguishes several shapes, making illegal states unrepresentable and enabling automatic narrowing via control-flow analysis.

Self-check

1. type A = { id: string }, const x = { id: "1", extra: true }. Is const a: A = x legal? What about const b: A = { id: "1", extra: true }?

The first is legal: structural typing only requires "at least" id. The second errors: when an object literal is assigned directly to a typed variable, TS performs an extra "excess property check", because that usually means a typo. This special check only applies to "fresh" object literals.

2. An LLM returns JSON and you do JSON.parse(text) as Summary. Name at least two runtime problems the type system can't catch.

Missing fields (the model forgot tags); wrong field types (tags as a comma-separated string instead of an array); unknown extra fields; enum values out of range (status "completed"). Use SummarySchema.safeParse and retry or degrade on failure.

3. You add { status: "archived"; url: string } to Bookmark. What happens in a codebase with exhaustiveness checks vs one without?

With them: every switch missing archived fails to compile, listing exactly what to update. Without: everything compiles, archived bookmarks fall into a default branch or get treated as some error state, and the bug surfaces only when a user hits it.