Skip to content

3.3 — Type Systems

3.1 mentioned that the compiler's semantic-analysis stage performs type checking — rejecting number * string as meaningless. 3.2 showed interpreters paying a per-operation cost to ask what type a value is, and JITs speculating that it hasn't changed. Both were circling the same question, and it deserves its own chapter, because the answer shapes languages more than almost any other decision: what is a type, who checks it, and when?

This is territory where engineers hold strong opinions and often argue past each other, largely because the vocabulary is muddled — "strongly typed" in particular means about four different things depending on who says it. So this chapter builds the concepts precisely: what a type actually is, the genuine axes of variation (static vs dynamic, strong vs weak, nominal vs structural), how a compiler can figure out types you never wrote down (inference), how generics let one piece of code work for many types safely, and the subtle rule (variance) that decides when a "list of dogs" may be used as a "list of animals." By the end you'll be able to place any language precisely and explain what its choices buy and cost.

1. What a type actually is

Strip away the folklore. A type is a label attached to a value that says what kind of thing it is and therefore what operations are legal on it. Nothing more mystical than that.

Recall 1.4: in memory, everything is bits, and the identical byte pattern 01000001 could mean the number 65, the letter A, or part of a colour. The bits themselves carry no meaning; meaning comes from the agreed interpretation. A type is that agreement, made explicit and enforced. Saying age has type number declares: interpret these bits as an integer, and permit arithmetic on them. Saying name has type string declares: interpret these bits as text, and permit concatenation and length — but not subtraction, because "Alice" minus "Bob" is meaningless.

So a type system is the set of rules a language uses to assign types to values and expressions, and to decide which operations are permitted. Its purpose is to catch a specific class of error — applying an operation to a kind of thing that operation makes no sense for — before that error causes damage. The entire debate is over when that check happens and how strictly it's enforced.

2. The first axis: static vs dynamic (when is it checked?)

This is the axis people actually mean most of the time.

Static typing checks types at compile time — before the program ever runs. Every variable and expression has a type known from the source (either written down or inferred), and the compiler proves the whole program is type-consistent, refusing to produce an executable otherwise. C, C++, Java, Go, Rust, and TypeScript work this way.

java
int age = 30;
age = "hello";   // rejected by the compiler — the program never runs

Dynamic typing checks types at runtime, as operations execute. Variables are just names that can hold any value; each value carries its type with it, and the check happens at the moment of use. Python, JavaScript, and Ruby work this way.

python
age = 30
age = "hello"    # perfectly fine — the name now holds a string
age - 1          # error, but only when this line actually runs

The trade is real in both directions, and neither side is simply "better":

StaticDynamic
Errors caughtbefore running, across the whole programonly when that line executes, in tested paths
Speedfaster — types known, so no runtime checks; values can be laid out compactlyslower — every operation checks types (section3.2's interpreter cost)
Toolingexcellent — precise autocomplete, safe automated refactoringweaker — the tool must guess what a name holds
Flexibilitymore ceremony; some valid programs are rejectedexpressive, terse, quick to prototype
Documentationtypes document intent in the code itselfintent lives in comments, docs, and hope

The most underrated benefit of static typing is not bug-catching but tooling and refactoring at scale: when the compiler knows every type, "rename this method everywhere" and "find all callers" become provably correct operations (3.1's AST drill), and autocomplete knows exactly what's available. On a million-line codebase touched by hundreds of engineers, that mechanical certainty is worth enormous amounts — which is why large systems drift toward static typing (and why TypeScript conquered large JavaScript codebases, Chapter 3.7).

The most underrated benefit of dynamic typing is speed of exploration: when you don't yet know the shape of your problem, not having to satisfy a type checker on every intermediate step is genuinely liberating — which is why scripting, data exploration, and prototyping gravitate there.

3. The second axis: strong vs weak (how strictly is it enforced?)

Here's where vocabulary goes wrong. Many people say "strongly typed" when they mean "statically typed." They are different axes, and conflating them causes endless confusion. Static vs dynamic asks when types are checked; strong vs weak asks how much the language will silently bend the rules.

A strongly typed language refuses to silently reinterpret a value as a different type; if you want a conversion, you ask for it explicitly. A weakly typed language performs implicit conversions (coercion) to make an operation "work" rather than fail.

The comparison that makes this click uses two dynamically typed languages — proving the axes are independent:

python
# Python — dynamic, but strong
"5" + 3      # TypeError: refuses to guess whether you meant 8 or "53"
javascript
// JavaScript — dynamic, and weak
"5" + 3      // "53"  — silently converts 3 to a string
"5" - 3      // 2     — silently converts "5" to a number!

JavaScript's + prefers string concatenation while - has no string meaning, so it coerces the other way — the same operands produce a string in one case and a number in the other. This is a fountain of real bugs and the source of JavaScript's reputation for surprising behaviour (the notorious [] + {} puzzles all stem from coercion rules). It's also precisely why TypeScript exists and why careful JavaScript uses === (which compares without coercion) rather than == (which coerces first, so "5" == 5 is true).

So the four combinations all exist and are worth being able to name: static + strong (Java, Rust, Go), static + weak (C — you can freely reinterpret a pointer's type and it will oblige), dynamic + strong (Python), dynamic + weak (JavaScript, PHP). "Strong" and "static" are genuinely independent.

4. Type inference: static typing without the paperwork

An old objection to static typing was the verbosity — writing types everywhere:

java
Map<String, List<Integer>> scores = new HashMap<String, List<Integer>>();

Type inference dissolves that objection. The compiler deduces a type from context instead of demanding you write it. If you write let x = 42, the compiler reasons: the literal 42 is an integer, therefore x is an integer — permanently and statically. You get the full safety of static typing with the brevity of dynamic code:

rust
let count = 42;              // inferred: integer
let names = vec!["a", "b"];  // inferred: vector of strings

This is not dynamic typing: count still has one fixed type known at compile time, and assigning a string to it is still a compile error. The type simply wasn't written. Modern statically-typed languages (Rust, Go, C#, Kotlin, Swift, TypeScript) lean on inference heavily, which is why the old "static typing is too verbose" complaint is largely obsolete. The practical guidance that emerged: let inference handle local variables, but write types explicitly at boundaries — function parameters and return types, public APIs — because there they serve as checked documentation and give better error messages.

5. Nominal vs structural: what makes two types "the same"?

A subtler axis, and the one that explains a genuine difference between TypeScript and Java. When are two types compatible?

Nominal typing (Java, C#, Rust): compatibility is by name and declaration. A Duck is a Duck only if it was declared as one (or declares that it implements Duck). Two classes with identical fields are still different types, because they have different names.

Structural typing (TypeScript, Go's interfaces): compatibility is by shape. If a value has the required members, it is compatible — no declaration of intent needed. This is "if it walks like a duck and quacks like a duck, it's a duck," enforced statically:

typescript
interface Point { x: number; y: number }

function show(p: Point) { /* … */ }

const thing = { x: 1, y: 2, label: "origin" };
show(thing);   // accepted — it has x and y, so it structurally IS a Point

thing never declared any relationship to Point, yet fits. In Java, the equivalent would require class Thing implements Point. The trade: structural typing is flexible and great for adapting code you don't own (you can satisfy an interface without modifying the original type — invaluable when wrapping libraries), while nominal typing prevents accidental compatibility (two unrelated types that happen to share fields — say Meters and Feet, both {value: number} — stay distinct, catching a real category of bug). Go deliberately mixes both: named types are nominal, but interfaces are satisfied structurally.

6. Generics: one piece of code, many types, still safe

Consider writing a "box that holds one value." Without generics, you face an ugly choice: write a separate IntBox, StringBox, UserBox… (duplication), or write one Box holding a generic "any" type and cast on the way out (unsafe — the compiler can no longer help, and a wrong cast becomes a runtime crash).

Generics (also called parametric polymorphism) solve this by letting a type be a parameter. You write the code once, with a placeholder standing in for "some type to be chosen later," and the caller supplies it:

typescript
class Box<T> {          // T is a type parameter — a placeholder
  constructor(private value: T) {}
  get(): T { return this.value }
}

const b = new Box<number>(42);
const n: number = b.get();   // compiler knows this is a number — no cast

The notation <T> is universal across languages (Box<T>, Map<K, V> for two parameters — key and value). The payoff: one implementation, full type safety at every use. The compiler checks each instantiation separately, so Box<number> and Box<string> are properly distinct, and nothing is lost to casting. Every collection library depends on this — List<User>, Map<String, Integer> — which is why generics appear in essentially every modern statically-typed language.

You often want to constrain the placeholder: a generic max function only makes sense for things that can be compared. That's a bounded type parameter — "T, but it must support comparison":

typescript
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;   // safe: T is guaranteed to have .length
}

Now the code may use .length because the bound guarantees it, and calling it with something lacking .length is a compile error. Bounds are how generics stay both general and useful.

7. Variance: is a List<Dog> a List<Animal>?

This is the deepest idea in the chapter, it trips up experienced engineers, and once seen it can't be unseen.

Clearly a Dog is an Animal — that's ordinary subtyping. So it feels obvious that a List<Dog> should be usable wherever a List<Animal> is expected. It is not, and here's the proof:

java
List<Dog> dogs = [dog1, dog2];
List<Animal> animals = dogs;   // suppose this were allowed…
animals.add(new Cat());        // …perfectly legal: a Cat IS an Animal
Dog d = dogs.get(2);           // 💥 but that's a Cat, and dogs is a List<Dog>

Allowing it would let you smuggle a Cat into a list of Dogs through the aliased reference, breaking type safety. So a mutable List<Dog> is not a subtype of List<Animal> — this property is called invariance.

Variance is the rule for how subtyping of the parts relates to subtyping of the whole, and the resolution is elegant once you split reading from writing:

  • Covariant ("varies with") — safe when you only read (produce) values. A read-only List<Dog> is usable as a read-only List<Animal>, because everything you pull out is a Dog, and a Dog is a valid Animal. No way to smuggle anything in.
  • Contravariant ("varies against") — safe when you only write (consume) values, and it reverses direction. A function that accepts any Animal can be used wherever a function accepting Dog is required — it's more general than needed, so it handles every Dog fine. (A function that only accepts Dogs cannot stand in where any Animal might arrive.)
  • Invariant — required when you both read and write, as with a normal mutable list. Neither substitution is safe.
Covariant (read-only)List<Dog>List<Animal>usable as ↑ (safe)Contravariant (write-only)fn(Animal)fn(Dog)usable as ↓ (reversed)Invariant (read + write)mutable List<Dog>mutable List<Animal>neither direction is safe
Figure 1 — Variance. Read-only containers are covariant (Dog-list works as Animal-list). Consumers are contravariant (a function taking any Animal works where one taking Dog is needed). Anything both readable and writable must be invariant.

The memorable rule of thumb, from Java's generics, is PECS: "Producer Extends, Consumer Super" — if a parameter produces values for you to read, make it covariant; if it consumes values you pass in, make it contravariant. Once you know variance exists, a whole class of previously-baffling compiler errors ("incompatible types: List<Dog> cannot be converted to List<Animal>") becomes obvious rather than arbitrary.

8. The expert lens

Types are machine-checked documentation — and that is their largest practical value. A comment saying "this returns a user's age in years" can rot silently; a signature getAge(u: User): number is verified on every build and can never drift from reality. On a large codebase this changes the economics of change: with types, "will this refactor break anything?" is answered mechanically by the compiler in seconds; without them, it's answered by test coverage and production incidents. This is the honest reason large organisations adopt static typing — not because dynamic languages are bad, but because at scale the cost of not knowing what a value is grows superlinearly with codebase size and team count.

The industry converged on gradual typing, and that's a genuine synthesis. The historical debate assumed you must pick a side. Practice found a third way: start dynamic and add types where they pay. TypeScript adds a static layer over JavaScript (Chapter 3.7); Python added optional type hints checked by tools like mypy; PHP added type declarations. All are gradual typing — types are optional, adoptable file by file, and checked before runtime, with the dynamic behaviour intact underneath. The lesson is a mature one: types are a tool with a cost, and the right amount varies by context — heavy at API boundaries, in shared libraries, and in long-lived core logic; light in a throwaway script or exploratory notebook.

Type checking is a form of proof — and it has hard limits. A type checker is genuinely proving a theorem about your program ("no operation is applied to an incompatible kind of value"), and it does so without running the code. That places it squarely against 1.7's wall: by Rice's theorem, non-trivial properties of program behaviour are undecidable in general, so no type checker can accept exactly the correct programs. Every real type system is therefore conservative — it must reject some programs that would actually have run fine. That's the precise, principled explanation for the everyday frustration of "I know this is safe but the compiler won't let me," and why escape hatches (any, casts, unsafe) exist. Richer type systems (dependent types, Rust's borrow checker) push the boundary further, accepting more correct programs at the cost of more complexity for the programmer — a permanent trade, not a temporary limitation.

Next chapter: types tell you what a value is; the next question is where it lives and who cleans it up. Chapter 3.4 takes on memory in languages — the stack and heap you met in 2.2, plus reference counting, tracing garbage collection, and Rust's ownership model.

Recall

  • A type is a label saying what kind of thing a value is and which operations are legal on it — the agreed interpretation of bits from 1.4, made explicit and enforced.
  • Axis 1 — when: static typing checks at compile time (safer, faster, superb tooling/refactoring); dynamic typing checks at runtime (flexible, terse, fast to prototype). Axis 2 — how strictly: strong refuses silent conversions; weak coerces ("5" + 3 is "53" in JS but a TypeError in Python — both dynamic, proving the axes are independent).
  • Type inference gives static safety without writing types everywhere (let x = 42 is permanently an integer). Convention: infer locals, write types at boundaries.
  • Nominal typing matches by declared name (Java); structural typing matches by shape (TypeScript, Go interfaces) — flexible, but permits accidental compatibility.
  • Generics (Box<T>) let one implementation serve many types with full safety; bounded parameters (T extends {length: number}) constrain them. Variance governs substitution: read-only is covariant, consumers are contravariant, read-write must be invariant (PECS) — which is why a mutable List<Dog> is not a List<Animal>.

Self-test: Give a one-sentence definition of a type. Which two axes do "static" and "strong" describe, and give a language for each of the four combinations. How is let x = 42 in Rust different from x = 42 in Python? Why is a mutable List<Dog> not a List<Animal>? Why must every type checker reject some valid programs?

Quiz Bank

FoundationalWhat is a type, and what is a type system for?

A type is a label attached to a value declaring what kind of thing it is and therefore which operations are legal on it. Since memory holds only bits (1.4) whose meaning comes from an agreed interpretation, a type is that agreement made explicit. A type system is the set of rules assigning types to values/expressions and deciding which operations are permitted; its purpose is to catch a specific error class — applying an operation to a kind of value it makes no sense for (e.g. subtracting two names) — ideally before it does damage.

FoundationalWhat is the difference between static and dynamic typing?

Static typing checks types at compile time, before the program runs: every expression's type is known (written or inferred) and the compiler refuses to build a type-inconsistent program (Java, Go, Rust, TypeScript). Dynamic typing checks at runtime, as each operation executes: variables hold any value, values carry their own type, and an error surfaces only when that line runs (Python, JavaScript, Ruby). Static buys earlier error detection, speed (no runtime checks), and precise tooling/refactoring; dynamic buys flexibility, brevity, and fast prototyping.

AppliedExplain the difference between 'statically typed' and 'strongly typed' with examples.

They're independent axes. Static vs dynamic = when types are checked (compile time vs runtime). Strong vs weak = how strictly the language enforces them, i.e. whether it silently coerces values to make an operation work. Proof they're independent: Python is dynamic but strong"5" + 3 raises a TypeError, refusing to guess. JavaScript is dynamic but weak"5" + 3 is "53" (number coerced to string) while "5" - 3 is 2 (string coerced to number). All four combinations exist: static+strong (Java, Rust), static+weak (C), dynamic+strong (Python), dynamic+weak (JavaScript, PHP). Conflating the two terms is the most common vocabulary error in this area.

AppliedWhat is type inference, and does it make a language dynamically typed?

Type inference is the compiler deducing a type from context rather than requiring you to write it: from let count = 42 it infers that count is an integer. It does not make the language dynamic — the type is fixed, known at compile time, and assigning a string to count remains a compile error; the type simply wasn't spelled out. Inference is why modern statically-typed languages (Rust, Go, Kotlin, Swift, TypeScript) are as terse as dynamic ones, obsoleting the old "static typing is too verbose" complaint. Good practice: rely on inference for local variables, but write types explicitly at function/API boundaries where they serve as checked documentation.

InterviewWhat is the difference between nominal and structural typing?

Nominal typing (Java, C#, Rust) determines compatibility by declared name/identity: a type matches only if it is that type or explicitly declares it implements it; two classes with identical fields remain different types. Structural typing (TypeScript, Go interfaces) determines compatibility by shape: any value possessing the required members is compatible, with no declaration of intent — statically-checked duck typing. Trade-off: structural is flexible (you can satisfy an interface without modifying a type you don't own — great for wrapping libraries), while nominal prevents accidental compatibility (e.g. Meters and Feet, both {value: number}, stay distinct — catching real unit-confusion bugs).

InterviewWhy is a mutable List<Dog> not a subtype of List<Animal>, even though Dog is a subtype of Animal?

Because it would break type safety through aliasing. If List<Animal> animals = dogs; were allowed, then animals.add(new Cat()) is perfectly legal (a Cat is an Animal) — but dogs now contains a Cat, so reading dogs.get(2) as a Dog blows up. Therefore a mutable generic container must be invariant. The general rule is variance: containers you only read from can be covariant (a read-only List<Dog> is safely a read-only List<Animal>, since everything produced is a Dog and Dogs are Animals); things you only write to are contravariant (a function accepting any Animal can stand in for one accepting Dog — it's more general); anything both read and written must be invariant. Mnemonic: PECS — Producer Extends, Consumer Super.

StaffWhy can no type checker accept exactly the set of correct programs, and what does that imply for practice?

Because type checking is proving a property of program behaviour without running it, and 1.7 established that non-trivial semantic properties of programs are undecidable in general (Rice's theorem, descending from the halting problem). A checker must therefore be conservative: to guarantee it never accepts a bad program, it must reject some programs that would in fact have run correctly. Implications for practice: (1) the familiar frustration "I know this is safe but the compiler won't allow it" is principled, not a bug — the checker cannot prove what you know from context; (2) this is why escape hatches exist (any, casts, unsafe) and why they should be narrow, documented, and rare, since each one transfers a proof obligation from the compiler to you; (3) richer type systems (generics → dependent types → Rust's borrow checker) shift the boundary to accept more correct programs, but at rising cost in language complexity and programmer effort — a permanent engineering trade-off, not a temporary limitation; (4) it justifies gradual typing: apply strong typing where the payoff is high (APIs, shared/core logic, long-lived code) and stay light where flexibility matters more (scripts, exploration).

Flashcards

FlashDefinition of a type

A label saying what kind of thing a value is and which operations are legal on it — the enforced interpretation of raw bits.

FlashStatic vs dynamic typing

Static: checked at compile time, before running. Dynamic: checked at runtime, when the operation executes.

FlashStrong vs weak typing

Strong: refuses silent type conversions (Python "5" + 3 errors). Weak: coerces implicitly (JS "5" + 3 = "53"). Independent of static/dynamic.

FlashType inference

The compiler deduces a type from context (let x = 42 → integer); still fully static, just not written out.

FlashNominal vs structural typing

Nominal: compatible only if declared so, by name (Java). Structural: compatible if the shape matches (TypeScript, Go interfaces).

FlashGenerics and bounded type parameters

Box<T> — write once, use with many types, fully checked. T extends {length: number} constrains T so the code may use .length.

FlashPECS / variance

Producer Extends, Consumer Super. Read-only = covariant, write-only = contravariant, read+write = invariant (why mutable List<Dog> ≠ List<Animal>).

Scenario Drill

DrillA team maintains a 300,000-line JavaScript codebase. Bugs like 'undefined is not a function' keep reaching production, and large refactors are terrifying. They ask whether to rewrite in a statically-typed language. Advise them.

A full rewrite is almost always the wrong answer — enormous cost, high risk, and it discards working, battle-tested behaviour to fix a problem that has a far cheaper remedy. Diagnose first: their symptoms are precisely what dynamic typing costs at scale — type errors surface only at runtime on paths that happen to execute (hence production discoveries), and refactoring is terrifying because nothing can prove which call sites break, so correctness rests on test coverage alone. The high-leverage fix is gradual typing via TypeScript (Chapter 3.7): it adds a static checking layer over JavaScript, is adoptable file by file (no rewrite — existing JS keeps running), and compiles away to plain JavaScript, so the runtime is unchanged. Recommended path: (1) enable TypeScript with permissive settings so the whole codebase still builds; (2) type the boundaries first — public APIs, shared modules, data models, and external I/O — since that's where types pay the most and where wrong assumptions cause the widest damage; (3) turn on strict/strictNullChecks incrementally per directory — note that "undefined is not a function" is overwhelmingly a null/undefined bug, exactly what strict null checking eliminates as a class; (4) treat any as debt, tracked and reduced.

The payoff they actually want is the refactoring one: once types are in place, "rename this," "change this signature," and "find all callers" become compiler-verified operations rather than hopeful greps (3.1's AST lesson). Reserve an actual language change for cases where the runtime is the problem (performance, concurrency model) — not the type discipline, which can be added in place. The staff framing:

match the intervention to the actual cost driver, and prefer incremental, reversible adoption over a rewrite whose risk is unbounded.