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.
- 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
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.
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
| Syntax | Meaning |
|---|---|
string, number, boolean | Primitives (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 | null | Union: a string, or null |
A & B | Intersection: 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 |
never | A 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:
- Always annotate: function parameters; return types of exported public functions (as a contract); entry points of external data.
- Let it infer: local variables, callback parameters, return values of simple functions.
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:
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
| Utility | Effect | LinkNote example |
|---|---|---|
Partial<T> | Make every field optional | Body of an update endpoint |
Pick<T, K> / Omit<T, K> | Keep / drop some fields | Omit<Link, "id" | "createdAt"> as create input |
Record<K, V> | Object with keys K and values V | Record<Status, string> status → label |
ReturnType<F> / Awaited<P> | A function's return type / unwrap a Promise | Derive data types from query functions |
keyof T / T[K] | Union of keys / type of a field | Link["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:
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
| Syntax | Meaning | Review guidance |
|---|---|---|
any | Turns off all checks on the value, and infects everything that touches it | 🚩 Almost always should become unknown + narrowing or validation |
x as T | Type 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-ignore | Ignore any error on the next line | 🚩 Not allowed; if truly needed, use @ts-expect-error with a comment explaining why |
as unknown as T | Double assertion, force any type into any other | 🚩 Basically admitting the model is wrong |
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:
- Read from the bottom up. The last line is usually the most specific mismatch ("property X is missing in type Y").
- Hover both sides. Look at the "expected" type and the "actual" type separately; the difference is the answer.
- 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.
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."- 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.