Gradual Typing
Static and dynamic typing in one program, with a dynamic type that is compatible with everything. The honest version of the story includes what `any` costs, why TypeScript checks nothing at runtime, and why the sound alternative has a performance problem nobody has fully solved.
If TypeScript checks my types, why does a value of the wrong type still reach production?
A program in which every expression has a type drawn from a lattice that includes a top-like *dynamic* type — any, Any, T.untyped — and where the compatibility relation is consistency rather than equality or subtyping. Consistency is reflexive and symmetric but deliberately not transitive: any is consistent with every type in both directions, which is exactly what makes it a hole rather than a wildcard.
A gradual system may erase a check at a typed/untyped boundary only where the static side proves the property. Where it cannot, it has two options and no third: insert a runtime contract that enforces the type as values cross, or accept that the static guarantee does not extend past that boundary. TypeScript takes the second option by construction — tsc emits no checks at all — so the guarantee ends at the compilation step and everything after it is convention.
Key points
- Gradual typing exists to let types be added to an existing untyped codebase incrementally, which is a practical requirement rather than a theoretical preference.
- The compatibility relation is consistency, not subtyping: the dynamic type is compatible with everything in both directions, which breaks transitivity deliberately.
anyis not "not yet narrowed" — it is assignable in both directions, so it disables checking for everything downstream of it.unknownis the safe version.- A type assertion converts nothing and checks nothing; it instructs the checker to stop disagreeing.
- A sound gradual system must insert runtime contracts at boundaries, and higher-order contracts are proxies that cost on every call for the life of the value.
- Measured overheads in sound gradual systems reach two orders of magnitude in the worst partially-typed configurations, which is why TypeScript chose erasure deliberately.
- The static guarantee ends at the compile step; restoring it at the edges requires a runtime validator, ideally one that also produces the static type.
Two type systems in one program
Gradual typing exists for a specific and entirely practical reason: there are enormous quantities of working, valuable, untyped code, and rewriting it is not on the table. A gradual system lets types be added incrementally, file by file or function by function, with the untyped remainder continuing to work. TypeScript over JavaScript, mypy and pyright over Python, Sorbet over Ruby, Typed Racket over Racket, Hack over PHP, and Flow are all instances of the same manoeuvre.
The mechanism is the dynamic type. Give the lattice a type that is consistent with everything, annotate what you can, and leave the rest at the dynamic type; the checker then verifies what it can see and waves through what it cannot. This is not a compromise bolted on afterwards — Siek and Taha's formulation of gradual typing is precisely the study of what such a system can and cannot guarantee, and the central result is that the guarantee is *local*.
The critical question, and the one that separates these systems into two genuinely different families, is what happens at the boundary. When an untyped value flows into a typed context, does anything check it? Typed Racket says yes and inserts a contract. TypeScript says no and emits nothing. Both are called gradual typing and they give you different products.
| System | Checks at runtime? | Where the guarantee stops | Blame on violation |
|---|---|---|---|
| TypeScriptimplementation | No — types are erased, nothing is emitted | At the compile step. Every runtime value is unchecked. | None. The wrong value simply flows on. |
| Flowimplementation | No — same erasure model | Same, with a different unsoundness surface. | None. |
| mypy / pyrightspec | No — annotations are ignored at runtime by CPython | At the checker. typing is documentation to the interpreter. | None, unless a library like pydantic or beartype adds it. |
| Sorbet (Ruby)implementation | Yes, at sig-annotated method boundaries | Extends into runtime for annotated methods. | Raises a TypeError naming the method and parameter. |
| Typed Racketimplementation | Yes — full higher-order contracts | The guarantee is complete; violations are caught. | Blame assignment names the guilty module. |
| Hack (PHP)implementation | Partially, depending on mode and type | Between the checker and the runtime, by configuration. | Varies by enforcement level. |
`any` is a hole, not a wildcard
JSON.parse returns any is a choice in TypeScript's bundled library declarations, not a language rule; projects can and do override it to return unknown, which turns every parse site into a compile error until a validator is added. Python's json.loads is annotated Any in typeshed for the same reason, with the same consequence and the same fix.The usual mental model of any is "a type I have not narrowed yet", which makes it sound like unknown. It is not. unknown is a top type: everything is assignable *to* it, and nothing is assignable *from* it without a check, so it is safe and inconvenient. any is assignable in both directions. That bidirectionality is what breaks transitivity, and transitivity is what every other guarantee was resting on.
The practical consequence is that a single any does not affect one expression, it affects everything downstream of that expression. JSON.parse returns any, so every field of the parsed object is any, so every value derived from those fields is any, so every function they are passed to has its parameter checks silently satisfied. Nothing is reported at any step, because at every step the checker was told the value could be anything.
This is why the useful measurement in a TypeScript codebase is not the percentage of annotated code but the reachability of any from the boundaries. The noImplicitAny flag catches the ones the checker inferred; it does nothing about explicit ones, about as casts, or about the enormous surface of any in third-party type definitions. unknown at the boundary, with a parse step to a real type, is the fix, and it is the same conclusion as [[nullability]] reaches from the other direction.
The type assertion as is the other hole and is worth naming separately, because it is not a cast — nothing is converted and nothing is checked. It is an instruction to the checker to stop disagreeing. x as User on a value that is not a user compiles perfectly, emits nothing, and produces a User-typed reference to something else.
1const raw = JSON.parse(body) // any2const user: User = raw.user // no error: any is assignable to User3charge(user.account.balance) // no error, whatever raw actually contained4 5// The same code with `unknown` refuses to compile until something checks:6const raw2: unknown = JSON.parse(body)7const user2: User = raw2 // error: unknown is not assignable to User8const user3 = UserSchema.parse(raw2) // a real runtime check; now the type is earned9 10// And the assertion, which converts nothing and checks nothing:11const u = {} as User // compiles. u.account is undefined at runtime.Only the third line of the second block does any work at runtime. The difference between the first block and the second is not annotation coverage — both are fully annotated — it is whether a check exists at the point where the data entered.
The soundness/performance dilemma
A gradual system that actually enforces its types must check values as they cross the boundary. For a first-order value that is cheap — check the shape once. For a function it is not, because you cannot check a function's type by inspecting it; you have to wrap it in a proxy that checks each argument and each result on every call, for as long as the function lives. The same applies to objects with methods, to arrays whose element type must be maintained, and to anything higher-order. These wrappers are contracts, and they are not free.
The result is that a sound gradual system can be very fast when a program is fully typed, very fast when it is fully untyped, and dramatically slower in the middle — precisely the configuration that gradual typing exists to support. Takikawa and colleagues measured this systematically across the possible typed/untyped configurations of Typed Racket programs and found overheads reaching two orders of magnitude in some configurations, publishing the result under the title "Is Sound Gradual Typing Dead?". The answer given by the community since has been a decade of work on reducing it — transient checks, space-efficient contracts, type-directed erasure — rather than a claim that the measurement was wrong.
This is the context in which TypeScript's decision reads as engineering rather than carelessness. Enforcing types at runtime in JavaScript would mean wrapping every boundary crossing in a proxy, in a language where boundary crossings are everywhere and proxies are slow, for a guarantee that competes with the ecosystem's existing validation libraries. The team chose erasure: zero runtime cost, zero output difference, and an explicit statement that the types are a development-time tool. Every consequence in this lesson follows from that one decision, and it is defensible — as long as everyone knows it was made.
The honest summary is that you can have soundness or you can have zero runtime cost at partially-typed boundaries, and nobody has yet shown how to have both at scale. Which one you want depends on whether your boundary is inside your program (where erasure plus discipline is usually fine) or at its edge (where you need a real check, and the language is not going to provide it).
What this means for how you write the code
The design conclusion is not "TypeScript is unsafe". It is that the checker covers one region and the boundary of that region is where your own checks have to live. Inside the region, the types carry real information and catch real mistakes at a cost of nothing at runtime, which is an excellent trade. At the boundary — HTTP responses, JSON, localStorage, environment variables, database drivers, model output, anything from a third-party library typed by hand — the guarantee is exactly zero, and only a runtime validator restores it.
That is why the validator libraries exist and why they are shaped as they are. A schema that both validates at runtime and produces the static type, so the two cannot drift apart, is the missing half of the language's design, supplied by userland. The pattern is the same in Python (pydantic), Ruby (Sorbet's runtime checks) and TypeScript, and it is the concrete form of the parse-don't-validate argument.
The second conclusion is about any hygiene. Because any is transitive through everything it touches, its cost is proportional to how far it can reach rather than to how often it appears. One any at a widely used boundary function disables checking across a large fraction of a codebase; a hundred anys in leaf functions do far less damage. Auditing by reachability rather than by count is the difference between a useful cleanup and a busy one.
And the third is about agent and model output specifically, since it is now a common boundary: text generated by a language model is untrusted input in exactly the sense this lesson means. A declared return type on the function that parses it is a claim about the code, not about the string — see [[structured-output-parsing]] and [[typed-tool-calls]] for what has to happen at that boundary.
1// The schema is the single source of truth for both the check and the type.2const User = z.object({3 id: z.string().uuid(),4 email: z.string().email(),5 plan: z.enum(["free", "pro"]),6})7type User = z.infer<typeof User> // static type derived from the runtime check8 9async function loadUser(id: string): Promise<User> {10 const res = await fetch(`/api/users/${id}`)11 return User.parse(await res.json()) // throws here, at the boundary, with a path12}Deriving the type from the schema is what stops the two drifting. Writing the interface by hand and the validator separately reintroduces exactly the gap the boundary check was there to close.
How it works
The steps, in the order the compiler takes them.
- The type lattice is extended with a dynamic type that sits outside the ordinary subtyping order.
- Assignability is replaced by consistency: two types are consistent if they are equal, related by subtyping, or if either is the dynamic type — which makes the relation non-transitive.
- Inference proceeds normally in annotated regions and falls back to the dynamic type wherever an annotation is absent and nothing can be inferred.
- At a typed/untyped boundary the system either inserts a check (a first-order shape test, or a higher-order proxy for functions and objects) or erases and inserts nothing.
- Where checks are inserted, a blame label records which side of the boundary is responsible, so a violation names the guilty module rather than the crash site.
- Where checks are erased, compilation emits the original untyped program unchanged — for TypeScript this is a syntactic transformation only, which is why
[[typescript-pipeline]]describes it as a type-checker plus a transpiler rather than a compiler.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A backend response shape changes, the frontend types still describe the old shape, everything compiles, and a field is undefined at render time — with the type annotation still confidently claiming it is a string.
- One
anyreturned by a widely used helper propagates through a call graph, and a refactor that should have produced fifty type errors produces none, so the breakage is discovered by users. - An
asassertion written during a migration outlives the migration; the underlying value drifts, the assertion keeps compiling, and the failure surfaces as a property access on undefined far from the assertion. - A team adopts a sound gradual system and adds types to a hot module in the middle of an untyped call graph; throughput drops sharply and the cause is contract wrappers on a boundary crossed in a loop, not the annotations themselves.
- Python type hints are added and mypy is run in CI, but a library with
Any-typed stubs sits in the middle of the flow, so an entire subsystem is unchecked while reporting full annotation coverage. - A model's JSON output is parsed and cast to a declared interface; a hallucinated field name produces
undefinedwhere a number was promised, and the arithmetic downstream yieldsNaNrather than an error.
When it helps
- Adding types to a large existing untyped codebase, which is the entire reason the design exists and the thing it does better than any alternative.
- Interoperating with untyped libraries and ecosystems without having to type them first.
- Prototyping, where the dynamic type lets a design settle before it is pinned down.
- Migration in general: any situation where the alternative is a rewrite you are not going to do.
When it hurts
- At any boundary carrying external data, where the static types describe a hope rather than a fact and only a runtime check can produce the guarantee.
- In a sound system, at a frequently crossed typed/untyped boundary, where contract wrappers turn a cheap call into an expensive one.
- When annotation coverage is used as the metric. A fully annotated codebase with
anyat its boundaries is unchecked where it matters and looks finished. - When the type declarations for a third-party library are hand-written and stale — the checker enforces a description of the library that no longer matches it.
What it costs
Every one of these is paid by something.
- Erasure buys zero runtime cost and perfect interoperability with the untyped ecosystem, and pays with a guarantee that stops at compile time: no value is ever checked, so every boundary needs a validator that the language will not provide.
- Runtime contracts buy a real, enforced guarantee with blame, and pay in throughput at exactly the partially-typed configurations gradual typing exists for — measurably so, and worst in the middle of a migration.
- A dynamic type buys incremental adoption and pays by making the compatibility relation non-transitive, which means guarantees no longer compose: two individually checked steps can compose into an unchecked one.
- Structural, permissive typing over an existing dynamic language buys the ability to describe code that already exists, and pays in soundness holes that must then be documented and lived with rather than fixed — see
[[type-soundness]].
What else you could do
What a different compiler or language does instead, and when that is better.
- A sound static type system from the start (Rust, Haskell, OCaml) gives a guarantee that holds everywhere inside the language, and gives up the ability to be adopted incrementally into an untyped codebase — you cannot get there from JavaScript.
- Contracts and runtime validation with no static types at all (Clojure spec, plain assertion libraries) check real values at real boundaries and give up editor tooling, refactoring support and compile-time feedback.
- A separate schema language as the source of truth (OpenAPI, protobuf, JSON Schema) generates both the runtime validator and the static types from one artifact, which is the pattern that actually closes the gap — at the cost of a code generation step in the build.
- Whole-program dynamic checking, as in Typed Racket's full contract mode, gives the strongest guarantee available in a gradual system and pays the performance cost knowingly, which is the right trade in some domains and not in a browser.
See it for yourself
The flag, dump or tool that shows you this directly.
tsc --noImplicitAny --strictestablishes the floor; then look for the explicit holes:grepforasand: anyand, more importantly, for functions returninganyfrom third-party declarations.tsc --declaration --emitDeclarationOnlyand reading the generated.d.tsshows exactly what the checker believes your public surface is —anyin the output is a hole you are exporting.type-coverage(an npm tool) reports the percentage of expressions with a non-anytype, which is a far better metric than annotation count because it follows propagation.mypy --strict --disallow-any-explicitplusmypy --html-reportshows per-module coverage and, crucially, which modules are unchecked because a dependency's stubs areAny.- To see erasure directly: run
tscon any file and diff the input against the output. The types are simply gone, and that diff is the most convincing possible demonstration that nothing is checked at runtime.
Plausible wrong readings
Stated the way a confident engineer states them.
- "TypeScript is a typed language, so type errors cannot reach production." The checker verifies the code you compiled. Every value entering from outside is unchecked, and
anydisables checking for everything it touches. - "
anyjust means I have not written the type yet." It means the checker will accept this value anywhere and accept anything here.unknownis the type that means what people thinkanymeans. - "Casting with
asconverts the value." Nothing is converted and nothing is emitted. It is a statement to the checker, and if it is false, the type is simply wrong from then on. - "Runtime checking would be strictly better; TypeScript just did not bother." Sound gradual typing has a well-measured performance problem at partially typed boundaries. The choice was made with the evidence available, and the cost of the other option is not small.
Misconceptions
The claim, and what is actually true.
any is unchecked everywhere it matters.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Gradual typing lets a program be partly typed and partly not, so types can be added to an existing codebase a bit at a time. The glue is a special type — any — that the checker accepts anywhere. TypeScript checks what it can see and then removes all the types before running, which means nothing is verified at runtime: if data arrives from the network in a shape the types did not expect, nothing notices.
practical
Two rules cover most of it. First, unknown at every boundary, never any: force a validation step where external data enters, and derive the static type from the same schema that does the validating so the two cannot drift. Second, audit any by reach rather than by count — one any returned from a shared helper disables checking across an entire subsystem, while a dozen in leaf functions do almost nothing. type-coverage measures the right thing here and annotation percentage measures the wrong one.
advanced
The formal core is that consistency is not transitive, and every practical consequence is a restatement of that. Non-transitivity means guarantees do not compose: two individually type-correct steps joined through the dynamic type are not a type-correct composition. Sound gradual typing restores composition by inserting contracts at every boundary crossing, and higher-order contracts are wrappers with unbounded lifetime, which is why the cost lands hardest on the partially-typed configurations the whole approach exists to support. The research directions since — transient or shallow checks that verify only top-level shape, space-efficient contracts that collapse chains of wrappers, and type-directed erasure that removes checks the static side has proven — are all attempts to buy back composition without the wrapper. Knowing that the trade is between composition and cost, and not between rigour and laziness, is what lets you make the decision at your own boundaries deliberately.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
tsc and of every alternative transpiler (Babel, esbuild, swc, the runtime type-stripping in recent Node and Deno). It is deliberate and stable, not an oversight, and it means that tsc output is the same JavaScript whether or not the check passed — --noEmitOnError is opt-in. Sorbet and Typed Racket make the opposite choice for the same feature name.from __future__ import annotations makes them strings that are never evaluated at all. Any runtime enforcement in Python comes from a library (pydantic, beartype, typeguard) reading them deliberately, never from the interpreter.any: A consistent with any and any consistent with B does not imply A consistent with B.If you were asked this in an interview
- Why does a
.d.tsfile that describes a library incorrectly produce no error anywhere, and what does that tell you about where the guarantee lives? - Explain the difference between
anyandunknownin terms of assignability, and say why the difference matters at a JSON boundary. - A colleague proposes emitting runtime type checks from TypeScript annotations. What is the argument against, and in what setting would it be the right idea anyway?