Static and Dynamic Typing, Compared Honestly
Not “safe versus unsafe”. Two placements of the same check, differing on seven axes that each cut both ways — and orthogonal to the strong/weak axis that gets confused with it constantly.
Should I reach for a statically typed language or a dynamically typed one, and what does each choice actually make hard?
Two placements of the same question. Statically, the type is a property of the *expression*, computed by the checker over the AST before any value exists; the program is a tree of judgments. Dynamically, the type is a property of the *value*, carried in the object header and read by the operation at the moment it executes; the program is a sequence of operations on tagged data. The question each placement answers: at what point in time is the program obliged to be right?
A dynamic checker may assume only that the code parsed — nothing about any name or value until it holds one. A static checker may assume names are resolved, and may propagate its own conclusions forward, which is what licenses [[type-erasure]] and unboxed representations downstream. A language that checks statically *and* keeps runtime tags is paying twice, and does so deliberately: Java keeps class pointers for dispatch and reflection, not because the checker needed them.
Key points
- Static versus dynamic is about when types are checked; strong versus weak is about whether values are silently reinterpreted. The axes are orthogonal and all four quadrants exist.
- Python is dynamically and strongly typed; C is statically and comparatively weakly typed. Conflating the axes is the reliable tell that someone learned the words rather than the ideas.
- Both approaches check the same thing. They differ on when, on what the checker can therefore see, and on who pays.
- The largest practical benefit of static checking is exact refactoring, and it compounds with codebase size and team turnover.
- The largest practical benefit of dynamic checking is that no correct program is ever rejected for being inexpressible.
- Static makes heterogeneous data and staged migration hard; dynamic makes whole-program questions — dead code, exhaustive rename, specialization — hard.
- TypeScript and Python annotations do not exist at runtime. They are build-time proofs, and the running program is as dynamic as it ever was.
Refusing the usual framing
NotImplemented from both operands’ methods raises TypeError; that is why 1 + "hello" is an error rather than a concatenation. The ECMAScript specification’s ApplyStringOrNumericBinaryOperator specifies the opposite for +: if either operand’s primitive is a string, both are coerced to strings and concatenated. Both behaviours are required by their specs — this is a language-design difference, not an implementation accident, and neither follows from being dynamically typed.The sentence “static is safe, dynamic is unsafe” is wrong twice, and the second error is the one worth fixing.
The first error is that it compares the wrong thing. Both languages check that + is defined for its operands. One checks it over all expressions before any run; the other checks it on the values that actually arrived. Neither skips the check. What differs is when the check happens, what it can therefore see, and who pays for it.
The second error is the conflation. Static versus dynamic is about *when* types are checked. Strong versus weak is about *whether values are silently reinterpreted across type boundaries*. They are orthogonal axes, and every quadrant is populated. Python is dynamically typed and strongly typed: 1 + "hello" raises rather than guessing. C is statically typed and comparatively weakly typed: a cast reinterprets the bytes and nothing checks anything at runtime. Treating “strongly typed” as a synonym for “statically typed” is one of the reliable signals in an interview that the candidate learned the vocabulary from blog posts.
| Comparatively strong: cross-type operations are refused or explicit | Comparatively weak: values are reinterpreted or coerced silently | |
|---|---|---|
| Static: checked over expressions, before executionspec | Java, Rust, Haskell, Go, Kotlin, Swift — mixing types requires an explicit conversion | C and C++ — a cast reinterprets bytes; implicit integer and pointer conversions happen without a diagnostic |
| Dynamic: checked on values, during executionspec | Python, Ruby, Smalltalk, Clojure, Erlang — 1 + "1" raises | JavaScript, PHP, Perl, awk — 1 + "1" produces "11", [] + {} produces "[object Object]" |
Seven axes that actually differ
Comparing on “safety” produces an argument. Comparing on the axes below produces a decision, because every row has a cost on both sides.
The row that decides most real projects is the last one. Ask what each choice makes *hard*, not what it makes possible — both make everything possible.
| Axis | Statically checked | Dynamically checked |
|---|---|---|
| When the check happens | Over all expressions, before any run. Unreached branches are checked as hard as hot ones. | On the values that arrive, at the moment the operation runs. An unexecuted branch is never checked at all. |
| Expressiveness | Bounded by what the rules can express. Correct programs whose invariant the rules cannot state are rejected. | Unbounded: anything the runtime can do is expressible, including shapes that no static system would accept. The proof obligation moves to you. |
| Tooling leveragetypical | Rename, extract, find-all-references and completion are exact, because the tool knows the types. [[language-server]] gets its answers for free from the checker. | Tooling is heuristic. Completion guesses from runtime traces, docstrings or gradual annotations, and rename is a text search with hope attached. |
| Runtime check costimplementation | The type check itself is gone. Values can be unboxed and calls made direct — see [[monomorphization]]. | Every operation reads a tag and dispatches. Mitigated, not removed, by [[inline-caches]] and speculation in a JIT. |
| Inference and annotation burden | Somebody writes the types. How many depends entirely on [[type-inference]]: ML asks for almost none, older Java asked for all of them. | Zero, until you want the tooling back — at which point gradual annotations arrive with most of the burden and part of the guarantee. |
| Refactoring safety | Change a type, recompile, and the checker enumerates every site that must change. This is the single largest practical benefit and it scales with codebase size. | Change a shape and find the sites by test coverage, grep and production. Works well up to a size that varies by team and stops working somewhere. |
| What it makes hard | Heterogeneous data, metaprogramming, wire formats that grew organically, and any invariant the rules cannot state — which is where casts and any come from. | Whole-program reasoning: dead-code detection, exhaustive rename, ahead-of-time specialization, and knowing whether a rare branch is even coherent. |
What each one makes hard, concretely
A statically checked language makes *heterogeneity* expensive. Parsing a decade-old JSON payload whose status field is sometimes a string, sometimes an integer and sometimes absent takes a union, a custom deserializer and a decision about the absent case — work that a dynamic language defers until a value shows up wrong. It also makes staged migration hard: you cannot half-type a module, so a large annotation project is all-or-nothing per file unless the language supports [[gradual-typing]] explicitly.
A dynamically checked language makes *whole-program questions* expensive. “Is this function still called?” has no exact answer without running everything. “What are the possible shapes of this argument?” is answerable only by instrumentation. Renaming a method across a large codebase is a text edit with a test suite as the oracle, and the failure mode is not a compile error but an AttributeError on the one path nobody exercised.
Both make the same thing hard at the same place: the boundary. Static code needs a parser at the edge to produce something the checker can reason about; dynamic code needs a check at the edge because there will not be one later. The languages disagree about the middle and agree about the edges, which is why parse, don’t validate reads as good advice in both.
- Codebase size and team turnover push toward static: the refactoring guarantee compounds, and it is the benefit that grows fastest with headcount.
- Short-lived, exploratory or heavily data-shaped work pushes toward dynamic: the annotation bill is paid up front and the refactoring benefit is never collected.
- A performance argument for static typing is really an argument about unboxing and direct calls, not about the check — and a good JIT recovers much of it. Measure rather than assume, on the implementation you actually ship.
- Neither choice removes the need for checks at the process boundary. Bytes arriving over a socket were typed by nobody.
The two families have been converging for twenty years
__annotations__ and does not check them; from __future__ import annotations (and PEP 649’s lazy evaluation in 3.14) changes when they are evaluated, never whether they are enforced. Enforcement requires a third-party decorator such as typeguard, or pydantic, which validates at model boundaries only. The TypeScript compiler emits no type information into the JavaScript at all — the emitDecoratorMetadata flag is the narrow exception, and it is opt-in and limited to decorated declarations.Dynamic languages grew optional static layers: TypeScript over JavaScript, mypy and pyright over Python, Sorbet over Ruby, Elixir’s gradual set-theoretic types. These are [[gradual-typing]] in practice, and the honest reading is that teams wanted the refactoring and tooling guarantee badly enough to pay the annotation bill after the fact.
Static languages grew dynamic escape hatches and inference: dynamic in C#, Any in Kotlin and Swift, auto and std::variant in C++, reflection everywhere, and [[type-inference]] sophisticated enough that modern Java, Rust and Kotlin read close to a scripting language at the statement level.
What has not converged is the runtime consequence, and this is the part worth remembering. TypeScript erases entirely: tsc type-checks and then emits JavaScript with no types in it, so the running program is exactly as dynamically typed as before — see [[typescript-pipeline]] and [[type-erasure]]. Python annotations are stored as metadata and evaluated by tools, not by the interpreter, so a wrong annotation changes nothing at runtime. In both cases the type system is a build-time linter with a proof behind it, and the runtime remains dynamic. That is not a criticism; it is the design, and mistaking it for enforcement is how a service ends up with a number field holding "42".
How it works
The steps, in the order the compiler takes them.
- Statically: name resolution binds identifiers, the checker assigns a type to every expression by rule, failures are reported with a span, and the types are then either erased or partially retained for the back end.
- Dynamically: every value carries a tag — a type pointer, a class field, an NaN-boxed discriminant — and every primitive operation reads the tags of its operands before deciding what to do.
- Dynamic dispatch on the tag is the hot path, so real implementations memoize it: an inline cache records the shape seen last time and skips the lookup while it keeps matching —
[[inline-caches]]. - A static back end can skip all of that and emit the operation directly, which is what makes unboxed layout and direct calls legal in the first place.
- A gradual system inserts a check at each boundary between checked and unchecked code, so the guarantee holds inside the typed region and is re-established at its edge.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A dynamically typed service fails in production on a code path that no test reached:
AttributeError: NoneType object has no attribute id, three weeks after the field was made optional upstream. - A statically typed codebase accumulates casts and
anyat the boundary where the shapes were awkward, and the guarantee everyone believes they have holds everywhere except where the data enters. - A large rename in a dynamic codebase silently misses a call site constructed by string, and the failure surfaces as a missing feature rather than an error.
- A team argues about static versus dynamic when the real problem is that nothing parses at the boundary, and both languages would have failed the same way.
- Annotations are treated as enforcement: a Python function annotated
-> intreturns a string, every tool is happy because nobody ran the checker in CI, and the value propagates into a database column. - A static type is used as documentation for a cross-service contract, drifts from the wire format after a deploy, and both services type-check while exchanging incompatible data. A type name is not a shared theorem across a process boundary.
When it helps
- Choosing a language for a system with many contributors and a long life, where the refactoring guarantee is the dominant term.
- Explaining to a team why adding
mypyortscto CI changes anything: without CI enforcement, annotations are comments with syntax. - Diagnosing an argument that has stalled. Almost always one party means “when is it checked” and the other means “does it coerce”, and naming the two axes ends it.
- Deciding where to spend a typing budget in a gradual codebase: boundaries first, because that is where the guarantee is re-established for everything behind them.
When it hurts
- Using the distinction to predict performance. Runtime cost is decided by representation and dispatch strategy, not by when checking happened, and a good JIT closes much of the gap — see
[[why-runtime-information-helps]]. - Using it to predict safety. A statically checked program with a permissive deserializer and a dynamically checked one with a strict parser at the edge are in the same position for the failures that actually occur.
- Applying it to a language rather than an implementation. “Statically typed” is a property of the language’s rules; how much of that survives to runtime is a property of the implementation, and the two differ.
What it costs
Every one of these is paid by something.
- Static checking buys exact refactoring and exact tooling, and pays in annotation burden, in correct programs rejected, and in build time that shows up on every developer’s every save.
- Dynamic checking buys immediate expressiveness and zero annotation cost, and pays in per-operation tag reads, in tooling that can only guess, and in a class of failure that is discovered by users rather than by CI.
- Adding a gradual layer to a dynamic codebase buys most of the tooling and part of the guarantee, and pays twice: annotation work on existing code, plus a permanent second build step that must stay green or the guarantee silently lapses.
- Keeping runtime type information in a statically checked language buys reflection, serialization and debugging, and pays in object header space, in the
[[monomorphization]]opportunities forfeited, and in the parametricity the language can no longer promise.
What else you could do
What a different compiler or language does instead, and when that is better.
- Gradual typing as a first-class language feature rather than an external tool — Dart’s sound null safety, Elixir’s set-theoretic types, Typed Racket — which puts the boundary checks in the language’s own semantics instead of in a linter. See
[[gradual-typing]]. - Contract systems: Clojure spec, Eiffel, Racket contracts. They check richer properties than any of these type systems, on the run that happened, with blame assignment that says which module violated the agreement.
- Soft typing and type inference *for* a dynamic language, where a tool infers types without requiring annotations and reports only definite errors — historically Soft Scheme, currently the shape of Elixir’s and Erlang’s dialyzer-style analysis.
- Schema-at-the-boundary with a dynamic middle:
pydantic,zod, Protocol Buffers. The strongest guarantee is placed exactly where untrusted data enters and nowhere else, which is often the right answer regardless of the language.
See it for yourself
The flag, dump or tool that shows you this directly.
python -c "import dis; dis.dis(lambda a, b: a + b)"showsBINARY_OPwith no type information at all: the decision is made at runtime from the operands’ tags.tsc --noEmitthentsc --outFile -on the same file: the first checks, the second shows that nothing about the types survives into the emitted JavaScript.python -c "def f(x: int) -> str: ...; print(f.__annotations__)"prints the annotations as data, demonstrating that the interpreter stores and ignores them.javap -con a compiled class shows the descriptors that survived erasure;javap -sprints the signature attribute where generic information was retained for reflection but not for dispatch.- Compiler Explorer with a Rust or C++
add(i32, i32)next to the CPython bytecode for the same function: the static back end emits one instruction, the dynamic one emits a dispatch.
Plausible wrong readings
Stated the way a confident engineer states them.
- “Dynamically typed means untyped.” Values are typed, precisely and strongly, in Python, Ruby and Smalltalk. What is untyped is the *expression*, before a value exists.
- “Static typing is faster.” The speed comes from unboxed representations and direct calls that static types make legal, not from the absence of a check. Say the mechanism or the claim is not checkable.
- “TypeScript makes JavaScript statically typed.” It makes the build statically checked. The emitted program is the same dynamically typed JavaScript, which is exactly why runtime validation at boundaries is still required.
- “Strong typing means static typing.” They are different axes. Python is strong and dynamic; C is static and comparatively weak. This one is worth correcting every time you hear it.
- “Adding type annotations will speed up my Python.” Not in CPython — they are metadata. Speedups from annotations come from tools that *use* them to generate different code, such as Cython or mypyc, which is a different mechanism entirely.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Both kinds of language check that operations make sense for their operands. A static language checks every expression before running anything; a dynamic language checks each operation as it happens, using tags carried by the values. Separately from that, languages differ on whether they silently reinterpret values across types — that is the strong/weak axis, and it is not the same question. Python is dynamic and strong. C is static and comparatively weak. If someone tells you dynamic means unsafe, they have merged two axes into one.
practical
Choose on refactoring pressure and data shape, not on ideology. Many contributors, a long-lived codebase and frequent structural change favour static checking, because the checker enumerating every affected call site is a benefit nothing else replicates. Deeply irregular data, short-lived scripts and heavy metaprogramming favour dynamic, because the annotation cost is real and the payoff is not collected. Whichever you pick, put a real parser at every boundary — socket, database, environment, model output — and make it produce a domain value rather than returning a validated blob. And if you adopt annotations in a dynamic codebase, put the checker in CI on day one: unenforced annotations decay into comments within a quarter.
advanced
The interesting version of this question is not about the source language but about what survives to the back end. Static checking is what makes erasure legal, which is what makes unboxed layout and direct calls legal, which is where the performance difference actually lives. A language can be statically checked and still keep every tag — Java does, for reflection and dispatch — and thereby forfeit most of it. Conversely, a dynamic language whose JIT observes stable shapes can specialize a call site to exactly the code a static compiler would have emitted, guard the assumption, and deoptimize if it breaks — [[guards]] and [[deoptimization]]. So the real spectrum is: how much type information reaches the code generator, and how confident is it allowed to be? Static checking makes that information free and certain. Dynamic execution makes it expensive and probabilistic. Everything downstream follows from which of those you are working with.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
sig does insert runtime checks by default, so “annotations are inert” is a Python and TypeScript fact, not a general one.+ as string-or-numeric: if either operand’s primitive value is a String, both are converted to String and concatenated. Java’s specification requires the same behaviour for + when one operand is a String, in a statically typed language — so string concatenation via + is a specification choice, not a consequence of dynamic typing.tsserver, rust-analyzer, gopls and IntelliJ answer rename and find-references from the checker’s own tables, while pylsp and Ruby language servers infer heuristically unless annotations or Sorbet are present. Pyright narrows this gap considerably on annotated Python and does not close it on unannotated Python.If you were asked this in an interview
- Give me a language that is dynamically typed and strongly typed, and one that is statically typed and weakly typed. Explain why those are different axes.
- A team adds TypeScript to a JavaScript service and removes its runtime input validation. What did they get wrong?
- What does a static type system make hard? Name two things, without saying “boilerplate”.
- Your Python service has full type annotations and no
mypyin CI. What guarantee do you have?
Connections
- Programming Languages & Runtime Internals — Value representation: tagged pointers, NaN boxing, object headers and hidden classesThe runtime cost of dynamic typing is a representation question, and the representations that make it cheap are the runtime’s subject. This lesson only needs to know that the tag exists and is read.
- Testing & Reliability Engineering — How test strategy changes when a checker covers part of the property spaceThe practical question — which tests a type checker lets you delete, and which become more important — is a testing-strategy decision, not a compiler one.