Static vs Dynamic Typing

Imagine two programmers writing the same function to add two values. One compiles her code. The compiler immediately rejects `add("hello", 5)` before the program ever runs. The other runs her code instead. The error only appears the moment that exact line executes. This might happen in production, perhaps months later. Both programmers are using types. The disagreement is about when the type constraints get checked. This single design decision is made once for an entire language. It shapes how bugs are caught and how fast programs run. It also affects how flexible code can be. Finally, it determines how much a developer must write versus infer. This question — static versus dynamic typing — is one of the most consequential design axes in programming language design. It touches compiler architecture, runtime system design, and developer tooling. It even shapes organizational practices at large software companies. Languages like C, Java, Rust, and Haskell check types before a program runs (statically). Languages like Python, JavaScript, and Ruby check types as the program executes (dynamically). Many modern languages blur this line. TypeScript, Python with type hints, and Racket's Typed Racket offer optional or gradual typing. This lets engineers choose the discipline per module. The stakes are concrete. A missing null check caught at compile time in a flight-control system can prevent a catastrophic failure. A dynamically typed script can adapt its behavior based on runtime shapes of JSON data. Such a script can be written and iterated on in minutes. Neither approach is strictly better. Each trades safety guarantees, flexibility, tooling quality, and performance characteristics against the other. Understanding those trade-offs precisely is a foundational skill. This holds for any engineer choosing a language or reasoning about program correctness. This article builds directly on What Is a Type System?. It gives you the vocabulary and mental models to compare static and dynamic disciplines rigorously. It covers what each promises and what each costs. It explains how the checking mechanisms actually work. It also shows where the frontier of "gradual typing" sits between them. This sets up the deeper machinery of Type Checking. It also introduces the formal guarantees studied under Type Safety and Soundness.

Key takeaways
  • Static typing checks type constraints before the program runs (at compile time); dynamic typing checks them while the program runs (at execution time).
  • Static typing catches a class of errors early and enables aggressive compiler optimizations and rich IDE tooling, at the cost of more upfront annotation and reduced flexibility.
  • Dynamic typing enables rapid prototyping and highly flexible, data-driven code, at the cost of deferring errors to runtime and paying repeated type-check overhead.
  • Both disciplines can be type-safe or type-unsafe independently — staticness and safety are different axes.
  • Type inference lets statically typed languages recover much of dynamic typing's brevity without sacrificing early checking.
  • Gradual typing (e.g., TypeScript, Python type hints) is a practical middle ground, mixing static and dynamic checks in one program.
The core insight

Static and dynamic typing differ only in when type constraints are enforced — compile time versus run time — and that single timing decision cascades into every trade-off in safety, flexibility, and performance.

The idea

A type system, as established previously, assigns types to program expressions and restricts which operations are legal on which types. The question this article addresses is when the system enforces those restrictions. In static typing, a compiler (or a separate static type-checking pass) analyzes the program's source text and rejects programs containing type errors before any execution begins. The type of every variable and expression is, in principle, determined without running the program — this is why static typing is sometimes described as reasoning about the program's shape rather than its behavior. In dynamic typing, the language runtime attaches type information to values (not variables or expressions), and checks are performed at the moment an operation is about to execute, e.g., "is this value actually a number before I add it?" This distinction is orthogonal to another important axis: type safety. A language is type-safe if it never permits an operation on a value of the wrong type to silently produce a meaningless or undefined result — instead it must raise a well-defined error or reject the program outright. Static typing does not automatically imply safety (C's `union` types and pointer casts allow a statically typed but unsafe escape), and dynamic typing does not imply unsafety (Python raises a clean `TypeError` rather than corrupting memory). The safety axis is explored fully under Type Safety and Soundness; here we hold it fixed and focus purely on timing. A second precise distinction worth naming: static type systems are typically conservative. Because they must decide, without running the program, whether every possible execution path is well-typed, they necessarily reject some programs that would actually run correctly (the type checker cannot see everything a dynamic run would reveal). This conservatism is not a design flaw — it is a mathematical necessity, a direct consequence of the undecidability of runtime behavior in general (an instance of Rice's theorem: any nontrivial semantic property of programs is undecidable in general). Static type checkers approximate program behavior soundly but incompletely; dynamic type checking, by contrast, is exact for the single execution path actually taken, but offers zero guarantee about paths not yet exercised.

How it works

Static type checking operates as a compiler phase, positioned after parsing and typically producing or consuming an abstract syntax tree (AST). It assigns a type to every subexpression using a set of typing rules and a symbol table (environment) mapping variables to declared or inferred types, then verifies that every operation's operand types match what the operation requires. ``` function TYPECHECK(expr, env): match expr: case Literal(v): return typeof(v) // base case: int, bool, string, ... case Var(name): if name not in env: error "undefined variable " + name return env[name] // base case: lookup case BinOp(op, left, right): tL = TYPECHECK(left, env) tR = TYPECHECK(right, env) if not COMPATIBLE(op, tL, tR): error "type mismatch: " + op + " on " + tL + ", " + tR return RESULTTYPE(op, tL, tR) case If(cond, thenBranch, elseBranch): tC = TYPECHECK(cond, env) if tC != Bool: error "condition must be Bool, got " + tC tT = TYPECHECK(thenBranch, env) tE = TYPECHECK(elseBranch, env) if tT != tE: error "branches disagree: " + tT + " vs " + tE return tT case Func(param, paramType, body): env' = env EXTENDED WITH param : paramType tBody = TYPECHECK(body, env') return Arrow(paramType, tBody) case App(f, arg): tF = TYPECHECK(f, env) tArg = TYPECHECK(arg, env) if tF is not Arrow(tArg, _): error "cannot apply " + tF + " to " + tArg return tF.returnType case Let(name, valueExpr, body): tVal = TYPECHECK(valueExpr, env) env' = env EXTENDED WITH name : tVal return TYPECHECK(body, env') ``` This recursion terminates because it strictly decreases the size of the AST at every recursive call — the base cases (`Literal`, `Var`) have no subexpressions left to check. The algorithm runs once, entirely before execution; if it reports no errors, the program is accepted and compiled/executed, otherwise it is rejected outright and never runs at all.

Dynamic type checking instead defers this exact same question — "are these operand types compatible?" — to run time, and it checks values rather than static expressions: ``` function EVAL(expr, runtimeEnv): match expr: case Literal(v): return TaggedValue(v, typeof(v)) case Var(name): return runtimeEnv[name] // no static lookup — literally run case BinOp(op, left, right): vL = EVAL(left, runtimeEnv) vR = EVAL(right, runtimeEnv) if not COMPATIBLE(op, vL.tag, vR.tag): raise TypeError at RUNTIME // caught only if this line executes return APPLY(op, vL, vR) case If(cond, thenBranch, elseBranch): vC = EVAL(cond, runtimeEnv) if vC.tag != Bool: raise TypeError at RUNTIME if vC.value: return EVAL(thenBranch, runtimeEnv) else: return EVAL(elseBranch, runtimeEnv) case App(f, arg): vF = EVAL(f, runtimeEnv) vArg = EVAL(arg, runtimeEnv) if vF.tag != Closure: raise TypeError at RUNTIME return APPLYCLOSURE(vF, vArg) ``` The crucial structural difference: `TYPECHECK` walks the entire static structure of the program exactly once, including branches never taken at runtime — the `else` branch is checked even if the program only ever executes `then`. `EVAL` walks only the single dynamic execution path actually taken, and re-derives and re-checks type compatibility at every single operation, every time it executes — even inside a loop executed a million times.

Figure
Where the check happens: static compile-time pass vs dynamic run-time interleaving
Static Typing Source code (AST) e.g. x + "hi" where x:int Type Checker (compile-time) checks ALL paths, once, before run ✓ Program runs ✗ Rejected, never runs Errors found BEFORE any execution — even in dead code / untaken branches. Dynamic Typing Source code — no upfront check Execute stmt 1 → check value tags → OK e.g. y = 3 + 4 Execute stmt 2 → check value tags → OK e.g. z = y * 2 Execute stmt 3 → tag mismatch → RAISE e.g. x + "hi" (only found HERE) Each check happens interleaved with execution, one statement at a time — untaken branches are never checked at all.

Complexity & correctness

Time complexity of static checking. For a syntax-directed type checker like the pseudocode above (no unification, all annotations explicit), each AST node is visited a constant number of times and does O(1) work (a table lookup and a comparison), so the total cost is O(n) in the size of the AST, `n`. If the language requires type inference (Hindley–Milner style, as in ML or Haskell), the checker must additionally solve a system of unification constraints; the classical algorithm is O(n · α(n)) with a union-find implementation in practice, though the worst-case complexity of full Hindley–Milner inference with let-polymorphism is known to be exponential (and even DEXPTIME-complete) in adversarially constructed programs due to nested generalization — a subtlety that matters for compiler engineers but is rarely observed in ordinary code. This static cost is paid once, at compile time, and is completely amortized away by the time the program executes even once.

Time complexity of dynamic checking. Each individual runtime type check (comparing a tag) is O(1), but the checks recur on every single execution of every operation, including inside loops and recursive calls. A dynamically typed loop executing an addition `n` times performs `n` runtime type checks, each with real cost: dereferencing a tagged value, branching on its tag, and dispatching to the correct implementation. This is why dynamically typed language implementations (CPython, MRI Ruby) are typically several times slower per operation than statically typed compiled code — the check that a static compiler pays for once is paid, at minimum, once per dynamic occurrence, and JIT compilers (V8, PyPy) exist substantially to try to recover this cost through runtime type specialization.

Space complexity. Static typing requires storing a symbol table during compilation — O(n) transient space, discarded after compilation, and it can erase type tags entirely from runtime representations (type erasure), yielding smaller and more cache-friendly runtime objects. Dynamic typing must keep a type tag attached to every value at runtime, permanently, adding a constant per-value overhead (commonly a machine word) that persists for the entire program's execution.

Correctness argument — what each discipline actually guarantees. The precise claim of a sound static type system is: if a program type-checks, then it will not encounter a type error at runtime — informally captured by the slogan "well-typed programs don't go wrong" (Robin Milner). This is proven, not merely observed, via two lemmas about the operational semantics of the language: 1. Progress — a well-typed expression is either a value or can take an execution step (it never "gets stuck"). 2. Preservation — if a well-typed expression takes a step, the result is still well-typed at the same type. By induction on the length of an execution, Progress + Preservation together imply that a well-typed program either runs forever, terminates with a value, or terminates cleanly — never with a stuck, type-confused operation. (This is exactly the machinery developed under Type Safety and Soundness.) Crucially, this guarantee covers every possible execution path, including ones a test suite never exercises. Dynamic typing offers a strictly weaker, path-local guarantee: if this particular execution reaches this particular operation, its runtime tags will be checked before the operation proceeds. Untaken branches receive no guarantee whatsoever until (if ever) they execute. The soundness of a dynamically checked language is therefore a statement about the runtime's per-operation checks being exhaustive and non-bypassable, not a statement about the whole program in advance.

Honest trade-off summary. Static typing buys a whole-program guarantee at the cost of conservatism (some correct programs are rejected) and annotation burden; dynamic typing buys unconditional flexibility (any value can flow anywhere) at the cost of deferring — sometimes indefinitely — the discovery of type errors, and at the cost of repeated runtime check overhead.

Worked example

Consider a function intended to double a numeric value, implemented in a hypothetical language once statically and once dynamically, then traced against three calls. ``` // Static version (explicit types) function double(x: Int) -> Int { return x + x; } // Dynamic version (no annotations) function double(x) { return x + x; } ```

CallStatic languageDynamic language
`double(21)`Compiler checks `x:Int`, `x+x:Int` — accepted at compile time; runs, returns `42`Runtime checks tags of `21` and `21`, both `Int`, executes `+`, returns `42`
`double(3.5)`Rejected before running — `3.5` is `Float`, not `Int`, does not match declared parameter typeRuntime checks tags, both `Float` (language permits numeric widening), executes `+`, returns `7.0`
`double("cat")`Rejected before running — `"cat"` is `String`, does not match declared parameter type; error appears the moment the compiler processes this call site, regardless of whether it's ever executedCompiles and loads fine; only at the moment execution actually reaches this specific call does the runtime check `"cat"` tag vs `+`'s requirements and raise `TypeError: cannot add str + str with this operator semantics` (or silently succeed as string concatenation, depending on language)

Two details matter here and are frequently missed. First, in the static language, `double(3.5)` is rejected even if that call site is inside a branch that never executes at runtime — for instance, `if (false) { double(3.5); }` — because static checking inspects the AST, not the trace. Second, in the dynamic language, `double("cat")` might not even be an error: many dynamically typed languages (Python, JavaScript) define `+` on strings as concatenation, so the "same" source code silently does something entirely different depending on the runtime tag of the argument — a flexibility that is a feature when intentional (generic, duck-typed code) and a bug when accidental (a typo passes a string where a number was meant). A second illustrative case: type-driven dispatch inside a loop, showing the repeated-cost argument concretely. ``` total = 0 for item in data: // data: mixture of Int and Float, 10^6 items total = total + item ``` In a statically typed language with a homogeneous array type `Array`, the compiler proves once, at compile time, that every `item` is `Int`, and the generated machine code for `total + item` is a single unchecked integer-add instruction, executed `10^6` times at native speed. In a dynamically typed language, `data` may hold a mixture of ints and floats; the runtime must inspect the tag of `item` on every one of the 10^6 iterations, branch to integer-add or float-add accordingly, and possibly re-box the result — the same asymptotic O(n) work, but with a materially larger constant factor per iteration, which is precisely why numerically intensive dynamic code is often rewritten in, or delegated to, statically typed/compiled libraries (NumPy's C core beneath Python is the canonical example).

Common pitfalls & traps

PitfallWhy it happensFix
Confusing "static" with "safe"Assuming a statically typed language can't crash from type confusionRemember unsafe casts (C's `union`, unchecked downcasts) can defeat static guarantees; safety and staticness are separate axes
Confusing "dynamic" with "untyped"Thinking Python/JS "have no types"Every value carries a type tag checked at runtime; dynamic languages are typed, just not statically checked
Assuming static typing eliminates all runtime errorsOvertrusting the type checkerStatic types don't catch logic errors, division by zero, array bounds, or most runtime exceptions — only type mismatches
Treating dynamic typing as "no discipline needed"Skipping tests because "it ran once"Untested branches carry zero type guarantee; comprehensive tests are the substitute for the missing static coverage
Over-annotating in inference-capable static languagesNot trusting the type inferencerLet inference (Hindley–Milner-style) fill in obvious types; annotate only public interfaces for clarity
Ignoring the conservatism of static checkersFrustration when a "clearly correct" program is rejectedUnderstand that static checkers must soundly approximate all paths; some correct programs are necessarily rejected (this is provably unavoidable)

Pioneers & history

Origins · 1 minRobin Milner and how this was discovered
RM
Robin Milner
1934–2010 · University of Edinburgh, ML and type inference

Milner designed the ML programming language and, with Luis Damas, formalized the type inference algorithm now called Hindley–Milner. He coined the phrase "well-typed programs cannot go wrong," giving the first rigorous articulation of what a static type system actually guarantees — the same Progress-and-Preservation argument used to reason about safety today.

History
1958
— John McCarthy's Lisp popularizes a dynamically typed, S-expression-based language design.
1958–1960
s — Algol 60 and Fortran establish static, declared-type disciplines in mainstream imperative languages.
1978
— Robin Milner publishes "A Theory of Type Polymorphism in Programming," founding Hindley–Milner inference for ML.
1990
s — Python (1991), Ruby (1995), JavaScript (1995) popularize dynamic typing for scripting and web programming at scale.
2012
— Microsoft releases TypeScript, mainstreaming gradual/optional static typing atop a dynamic host language.

Where this matters

Static typing underlies systems where correctness before deployment is paramount: aviation and automotive software (often written in statically typed, sometimes formally verified subsets of C or Ada), operating system kernels (C, increasingly Rust for its stronger static guarantees), and large-scale distributed systems at companies like Google and Meta, where Java, Go, and increasingly typed Python variants replace dynamic scripts as codebases and teams grow — the whole-program guarantee scales better across thousands of engineers who cannot mentally track every runtime path. Compilers for statically typed languages also enable aggressive optimizations (register allocation, inlining, dead-code elimination) that rely on knowing types in advance, which is one reason C, Rust, and Java-with-JIT achieve high throughput. Dynamic typing dominates rapid iteration contexts: data science and machine learning prototyping (Python, R), web scripting (JavaScript, PHP), and domains where the shape of data is only known at runtime, such as parsing arbitrary JSON or building flexible plugin architectures. The rise of gradual typing — TypeScript layered over JavaScript, Python's `typing` module and tools like `mypy`, Racket's Typed Racket — reflects industry's practical answer to this whole trade-off: let engineers add static guarantees incrementally to the parts of a codebase that need them (public APIs, critical business logic) while retaining dynamic flexibility elsewhere (glue code, experimental scripts), rather than forcing an all-or-nothing choice for an entire project.

Ask the tutor: How does gradual typing insert runtime checks at the boundary between statically and dynamically typed code to keep the whole system sound?

Is a statically typed language automatically faster than a dynamically typed one?

Usually, but not automatically. Static typing lets a compiler prove type facts once and generate specialized, unchecked machine instructions, avoiding repeated runtime tag checks — this is the main mechanism behind the typical speed advantage. However, actual performance also depends heavily on the compiler's optimization quality, memory layout choices, and runtime system (a poorly optimized statically typed interpreter can lose to a heavily JIT-optimized dynamic runtime like V8's JavaScript engine). The fair statement is: static typing removes a class of overhead that dynamic typing must pay, but real-world speed depends on the whole implementation, not typing discipline alone.

Can a dynamically typed language still catch type errors before running any code?

Only through separate, optional tooling layered on top of the language, not through the language's own execution semantics. Python's `mypy`, JavaScript's Flow, and TypeScript perform a static analysis pass over the source before execution, essentially adding an opt-in static type checker to a dynamically typed core language. This is exactly what "gradual typing" means: the base language remains dynamically checked at runtime, but an external static pass can catch a useful subset of errors earlier, at the cost of that pass itself being conservative and sometimes incomplete relative to the language's full dynamic flexibility.

Does static typing eliminate the need for testing?

No. Static type checking only guarantees the absence of type errors — mismatched operand types, calling something that isn't callable, and similar structural mistakes. It says nothing about logic errors: an off-by-one loop bound, an incorrect business rule, a division by zero, or an array index out of bounds are all perfectly well-typed and will still require tests to catch. Type systems and test suites address different, complementary categories of bugs; the deeper theory of exactly what a type system does and does not guarantee is developed under Type Safety and Soundness.

Why do dynamically typed languages remain popular despite the runtime overhead?

Because for many tasks the dominant cost is developer time, not CPU time, and dynamic typing reduces upfront annotation burden and allows extremely flexible, data-shape-agnostic code — for instance, processing arbitrary JSON without declaring a type for every possible shape in advance. In domains like exploratory data analysis, scripting, and rapid prototyping, the ability to write and iterate quickly outweighs the performance and early-error-detection advantages of static typing, especially since performance-critical inner loops can often be delegated to compiled, statically typed libraries underneath (as NumPy does for Python).

What exactly does "type inference" add to static typing?

Type inference lets a compiler deduce most or all types automatically from context, rather than requiring the programmer to write them explicitly everywhere. Introduced rigorously through the Hindley–Milner algorithm used in ML, Haskell, and (in modified forms) OCaml, F#, and Rust, inference preserves the full safety guarantee of static checking — programs are still verified before running — while recovering much of the brevity associated with dynamic typing. It does not weaken the guarantee; it only changes who writes the type annotations, the programmer or the compiler.

Is "gradual typing" the same as "dynamic typing with some comments"?

No — gradual typing is a formal discipline, not documentation. In systems like TypeScript, annotated regions of code are checked by the same style of static algorithm shown earlier, and the type checker inserts runtime checks at the boundary where statically typed code calls into unannotated dynamic code, to preserve soundness across the boundary. This boundary-check mechanism (sometimes formalized via "blame" tracking) is precisely what distinguishes gradual typing from merely dynamically typed code with optional human-readable type comments that the runtime ignores entirely.

Which should I choose for a new project: a statically or dynamically typed language?

It depends on the project's dominant risk. For long-lived, large-team, or safety-critical systems, static typing's whole-program guarantees and refactoring safety (via Type Checking tooling) usually pay for their upfront cost. For small scripts, rapid prototypes, or highly exploratory data work, dynamic typing's flexibility and low annotation overhead often win. Many production systems now split the difference: dynamically typed for glue and scripting layers, statically typed (or gradually typed) for core business logic and public interfaces — reflecting that this is a spectrum of trade-offs, not a binary choice.

Before this

Where this leads