What Type Systems Catch, and What They Do Not
Types eliminate a category of error and cannot express most of what matters. Where the boundary is, and how to move it with the tools you already have.
The argument about static and dynamic typing generates more heat than information, largely because both sides are describing different parts of a boundary neither states precisely.
For a separate people-operations perspective, the step-by-step guide covers using external systems to reduce working-memory load.
The useful question is not which is better. It is: which errors does a type system eliminate, which does it leave, and how far can you move that line with the type system you have.
What types eliminate
Passing the wrong thing. A function expecting a user receives an order. The largest category by count, and the one that dominates when refactoring.
Missing cases. Where a type has a fixed set of alternatives and the language checks exhaustiveness, adding a new alternative causes every unhandled site to fail compilation. This is one of the highest-value properties available, and it is the argument for sum types over string constants.
Null dereference — but only where the type system distinguishes nullable from non-nullable. In languages where every reference may be null, the type checker cannot help. In languages with an explicit optional type, this entire category disappears, and it is a large category.
Interface mismatches after a change. Change a signature and every caller fails to compile. This is what makes large-scale refactoring feasible, and it is the property people mean when they say static types "scale."
And the thing that is rarely stated: types are documentation that cannot go out of date. A signature tells you what a function needs and returns, and it is verified on every build.
What types do not catch
Wrong values of the right type. A tax rate of 40 where 0.4 was meant. Both are numbers. The type checker is content.
Wrong order of same-typed parameters. transfer(amount, fromAccount, toAccount) called with the accounts swapped. Everything is the right type and the money goes the wrong way. This is a real and expensive class of bug.
Logic errors. Correct types, wrong algorithm.
Anything about runtime state. Whether the list is empty, whether the connection is open, whether the file exists, whether the user is authorised.
Concurrency errors, in most languages. A few type systems encode ownership and thereby prevent data races; most do not.
Anything crossing a boundary. JSON from the network, rows from a database, environment variables. The type annotation on a parsed payload is an assertion about data you have not checked, and it is a claim rather than a guarantee.
This last one is where most production type errors actually occur, and it is the one people forget when they say the type checker has them covered.
Moving the boundary
The practical version of the argument: the line is not fixed, and moving it is mostly free.
Newtypes over primitives. UserId and OrderId as distinct types over the same underlying integer means passing one where the other belongs stops compiling. Almost every language supports this in some form and almost nobody uses it, which is why a large share of bugs are identifiers passed to the wrong parameter.
This is the single highest-return typing habit available, and it costs a line of code per type.
Types instead of booleans. SendEmail | SuppressEmail reads at the call site; true does not. Compare createUser(name, email, true, false) with a version taking two named alternatives.
Sum types instead of string constants, so exhaustiveness checking works and adding a state is a compiler error rather than a silent gap.
Make illegal states unrepresentable. A record with isLoading, data and error fields permits combinations that make no sense — loading and errored simultaneously, or neither loading nor having data. A sum type with Loading | Loaded(data) | Failed(error) permits exactly the three real states. Every impossible-state bug in that area disappears, permanently, without a single test.
Parse, do not validate. Instead of checking a string is a valid email and passing the string on, parse it into an Email type. The check happens once at the boundary, and everything downstream has a type-level guarantee rather than a convention that someone validated it earlier.
This is the technique that addresses the boundary problem above. Validate at the edge, produce a type, and the interior is safe.
Where gradual typing lands
Annotations added to a dynamic language — Python's type hints, TypeScript over JavaScript.
What you get: editor assistance, refactoring support, and a checker that catches the mismatch category.
What you do not get: runtime enforcement. Python's annotations are not checked at runtime. TypeScript's types are erased at compilation. An API returning something other than what the type says produces no error at the boundary — it produces a confusing failure somewhere later.
The mitigation is the same as above: validate at the boundary with something that actually runs, and derive the static type from that validation rather than declaring it independently. Libraries that generate both from one schema are the right shape for this.
Gradual typing is genuinely useful and it is not equivalent to a checked type system. Treating it as one is how people are surprised.
What the cost actually is
Honest accounting, since the trade is real.
More code, and sometimes more indirection to satisfy the checker.
Iteration friction, particularly in exploratory work where the shape is not yet known.
Type-level complexity has a cost curve that turns. Elaborate generic machinery can become harder to understand than the problem it prevents, and code that only its author can modify is expensive regardless of how correct it is.
Compile time, which for elaborate type-level work can be substantial.
The reasonable position: use types heavily for domain modelling and boundaries, lightly for internal glue, and stop when the type is harder to read than the bug it prevents.
The tests you still need
Types eliminate a category; they do not reduce the need for the tests that cover what remains.
Property tests are complementary in a specific way: types constrain the shape of inputs, properties constrain the relationship between inputs and outputs. A function correctly typed can still be wrong for every input.
Boundary tests for parsing and validation, because that is where the type claims are made and where they are false.
Concurrency and integration tests, which types do not reach in most languages.
See what tests actually catch.
The summary
Types catch wrong-thing errors and make refactoring tractable. That is a large and underrated benefit.
They do not catch wrong values, logic errors, runtime state, or anything about data crossing a boundary — which is where production type errors mostly live.
The boundary is movable, and the cheapest moves are newtypes over identifiers, sum types instead of string constants, and making illegal states unrepresentable.
Parse at the edge into a type, rather than validating and passing the raw value along. That single habit converts the weakest area into the strongest.