Skip to main content
Language Type Systems

Title 2: Gradual Typing in Practice: How Python, TypeScript, and Others Bridge the Divide

When a codebase grows beyond a few thousand lines, the freedom of dynamic typing starts to feel like a liability. Runtime type errors that slip into production, unclear interfaces that slow down new joiners, and refactoring that requires heroic manual testing — these are the signals that push teams toward static typing. But rewriting everything in a statically typed language is rarely feasible. Gradual typing offers a middle path: add type annotations incrementally, enforce them only where you choose, and keep the rest dynamic. This guide is for engineers and tech leads who already understand the basics of type systems and want to know how gradual typing works in practice across Python, TypeScript, and other languages. Who Should Adopt Gradual Typing — and When Gradual typing is not a one-size-fits-all solution.

When a codebase grows beyond a few thousand lines, the freedom of dynamic typing starts to feel like a liability. Runtime type errors that slip into production, unclear interfaces that slow down new joiners, and refactoring that requires heroic manual testing — these are the signals that push teams toward static typing. But rewriting everything in a statically typed language is rarely feasible. Gradual typing offers a middle path: add type annotations incrementally, enforce them only where you choose, and keep the rest dynamic. This guide is for engineers and tech leads who already understand the basics of type systems and want to know how gradual typing works in practice across Python, TypeScript, and other languages.

Who Should Adopt Gradual Typing — and When

Gradual typing is not a one-size-fits-all solution. It shines in projects where the cost of a full static rewrite is too high, but the pain of untyped code is mounting. Typical candidates include:

  • Long-lived Python or JavaScript backends that have accumulated hundreds of modules with unclear dependencies.
  • Data science or machine learning pipelines where exploration happens in notebooks but production code needs reliability.
  • Teams that are growing and need clearer contracts between services without halting feature development.

The right time to introduce gradual typing is when you have a stable core of business logic that changes less frequently than the experimental layers. Starting too early — during rapid prototyping — can slow down iteration without delivering safety benefits. Starting too late means the type annotation effort becomes a massive, risky migration. Many teams find a sweet spot around the 10,000-line mark or when the third developer joins a project originally written by one person.

Another factor is tooling maturity. Python’s mypy and Pyright, TypeScript’s built-in compiler, and Hack’s type checker each have different levels of strictness and performance. If your language’s gradual typing tool is still experimental or lacks IDE integration, the friction may outweigh the benefits. We recommend checking the current state of community support and CI integration before committing.

Finally, consider team culture. Gradual typing requires discipline: developers must annotate new code and gradually annotate old code. If the team is resistant or the codebase is rarely touched, the effort may stall. A successful rollout often starts with a small, motivated subteam that sets conventions and demonstrates value.

How Gradual Typing Works Under the Hood

At its core, gradual typing introduces a spectrum between fully dynamic and fully static. The type checker assigns a special type — often called Any or dynamic — to unannotated variables. Operations involving Any are assumed to be type-safe at compile time, with checks deferred to runtime. This is the key trade-off: you get static checking for annotated regions, but the unchecked parts can still cause runtime errors.

Languages differ in how they handle the boundary between typed and untyped code. TypeScript, for example, uses a structural type system and emits JavaScript regardless of type errors. Its gradual typing is “optional” — you can add types incrementally, and the compiler will report errors but still produce output. Python, with mypy or Pyright, follows a similar philosophy but relies on external tools rather than the core interpreter. Hack, on the other hand, enforces typing in strict mode and refuses to run code with type errors, making it more rigid.

The performance implications are subtle. In TypeScript, type annotations are erased at runtime, so there is zero runtime cost. In Python, type hints are also ignored by the interpreter (unless you use libraries like pydantic that leverage them for validation). However, the act of adding types can change how developers write code — for example, avoiding overly dynamic patterns like monkey-patching — which indirectly improves performance. Some gradual typing systems, like Typed Racket, actually use type information to generate faster code, but this is rare in mainstream languages.

One common misconception is that gradual typing guarantees safety. It does not. If a critical function is left unannotated, the type checker will not catch errors in it. The safety net only covers annotated regions. That is why teams must decide on a coverage target — say, 80% of function signatures — and enforce it in CI.

Comparing Python, TypeScript, and Hack

Each language implements gradual typing with different trade-offs. Let us compare three popular options across dimensions that matter for production codebases.

Python (mypy / Pyright)

Python’s type hint system, introduced in PEP 484, is the most widely used gradual typing system in the data science and backend world. Mypy is the reference checker, while Pyright (used by VS Code) offers faster performance. Python’s typing is optional by default — the interpreter ignores hints — so you can add annotations incrementally without breaking existing code. The ecosystem now supports generics, overloads, and protocols (structural subtyping).

Strengths: Huge community, rich type stubs for popular libraries, strong IDE integration. Weaknesses: No runtime enforcement (unless you add it yourself), performance of mypy on large codebases can be slow, and some dynamic patterns (e.g., **kwargs with arbitrary keys) are hard to type precisely.

TypeScript

TypeScript is the most successful example of gradual typing in the frontend world. It adds a static type layer on top of JavaScript, with a compiler that checks types and then emits plain JS. TypeScript’s type system is structural and very expressive, supporting union types, intersection types, mapped types, and conditional types. The any type allows opting out of checking, while unknown forces type narrowing before use.

Strengths: Excellent tooling (IDE autocomplete, refactoring), fast compiler (especially with project references), and a type system that can model complex data shapes. Weaknesses: The type system can be complex to learn, and the compiler’s leniency (it outputs JS even with errors) means teams must enforce strict mode in CI. Also, runtime type checks are still needed for external data (e.g., API responses).

Hack

Hack, developed by Facebook, is a gradual typing system for PHP. It runs on the HHVM runtime and offers both strict and partial modes. In strict mode, all code must be fully typed, and the type checker enforces soundness. Partial mode allows mixing typed and untyped code, with the checker flagging issues but not blocking execution.

Strengths: Sound type system (no false negatives in strict mode), good performance via HHVM, and strong integration with Facebook’s internal tooling. Weaknesses: Limited adoption outside Facebook, reliance on HHVM (not standard PHP), and a steeper learning curve for PHP developers.

Other languages worth mentioning: Dart (with null safety and type inference), Racket (with Typed Racket), and Julia (with optional type annotations). Each has its niche, but Python and TypeScript dominate the gradual typing conversation.

Trade-Offs: Coverage vs. Velocity

The central tension in gradual typing is between type coverage and development velocity. Adding types everywhere slows down initial development, but skipping types leaves safety gaps. Teams must decide where to draw the line.

A common approach is to annotate all public APIs and module boundaries first. This catches interface mismatches early and makes the codebase more navigable. Internal implementation details can remain untyped until they stabilize. Another strategy is to annotate “hot paths” — functions that are called frequently or that handle external input — since they are most likely to cause runtime errors.

The trade-off also affects tooling performance. In Python, running mypy on a large codebase with strict settings can take minutes. Teams often split the check into incremental runs or use a faster checker like Pyright. In TypeScript, the --noEmit flag can speed up CI checks by skipping output generation.

There is also a human cost. Developers accustomed to dynamic typing may resist verbose type annotations. This is where gradual typing’s incremental nature helps: you can start with type inference (e.g., TypeScript’s --strict mode with noImplicitAny) and only annotate when inference fails. Over time, as the team sees fewer runtime errors, buy-in grows.

One often overlooked trade-off is the risk of “type rot”. If the team stops maintaining type annotations — for example, after a major refactor — the annotations can become misleading. A function typed as returning int might actually return None in some paths, silently lying to the next developer. This erodes trust in the type system and defeats its purpose.

Implementation Path: From Zero to Gradual Typing

Adopting gradual typing is a multi-phase process. Here is a step-by-step approach that has worked for many teams.

Phase 1: Audit and Instrument

Run the type checker on your existing codebase with the most lenient settings. Count the number of errors and identify the modules with the highest error density. This gives you a baseline. Set up CI to run the checker but allow failures initially — you want to see the signal without blocking merges.

Phase 2: Define a Coverage Policy

Decide what “typed” means for your team. A typical policy might be: all new functions must have type annotations for parameters and return values; existing functions are annotated only when modified. Enforce this with a linter or a custom CI step that flags untyped functions in new pull requests.

Phase 3: Annotate Hot Modules

Start with the modules that handle external input (API endpoints, file readers, user input) or that are called by many other modules. These provide the highest safety return on investment. Use the type checker’s --strict mode on these files to catch edge cases.

Phase 4: Expand Gradually

Each sprint, pick a few modules to fully annotate. Track the type coverage percentage (many checkers report this). Aim for 80% coverage before considering the migration “complete”. Beyond 80%, the remaining untyped code is often glue or legacy scripts that are rarely changed.

Phase 5: Enforce in CI

Once coverage is above a threshold (say, 70%), make the type checker a mandatory CI step. Configure it to fail on errors in fully typed modules, but allow warnings in partially typed ones. Over time, tighten the rules until the entire codebase passes strict checks.

Throughout this process, invest in developer education. Hold a workshop on common typing patterns (e.g., generics, union types, type narrowing). Create a style guide that covers edge cases like Any vs. unknown. The more consistent the team is, the fewer false positives the checker will produce.

Risks of Getting Gradual Typing Wrong

Gradual typing is not without pitfalls. Here are the most common failure modes we have observed.

False confidence: Teams assume that because they have type annotations, their code is safe. But if the annotations are incomplete or incorrect, the type checker can still miss bugs. For example, a function annotated as returning int might return None in an error path — the checker will not catch this if the function body is not fully typed. The only defense is rigorous testing alongside typing.

Annotation debt: When the team stops annotating new code due to time pressure, the type coverage drops. Over a few releases, the codebase reverts to near-dynamic behavior, but with the illusion of safety. This is especially dangerous because developers may skip manual checks, trusting the type system that is no longer effective.

Tooling friction: In Python, mypy can be slow on large codebases, leading developers to disable it locally. In TypeScript, complex type expressions can cause long compile times. If the tooling is painful, developers will find ways to bypass it — using any everywhere or adding // @ts-ignore comments. Mitigate this by investing in fast CI and incremental checking.

Team resistance: Gradual typing requires a mindset shift. Developers who are used to dynamic languages may see type annotations as overhead. If the team is not on board, the migration will stall. Address this by demonstrating quick wins: show how a type annotation caught a bug that would have gone to production, or how autocomplete improved productivity.

Third-party code: External libraries may lack type stubs, forcing you to either write them yourself or accept Any types. This creates blind spots. In Python, the typeshed project provides stubs for many popular libraries, but niche packages may be untyped. In TypeScript, DefinitelyTyped covers most libraries, but quality varies. Plan for this by budgeting time to contribute stubs or using --strict only on your own code.

Frequently Asked Questions

Q: Does gradual typing guarantee no runtime type errors?
A: No. It only guarantees type safety for fully annotated regions. Unannotated code or code using Any can still have type errors. Gradual typing is a tool to reduce errors, not eliminate them.

Q: Should I use gradual typing on a new project or only on existing codebases?
A: Both. On new projects, you can start with strict typing from day one, which avoids migration pain. On existing projects, gradual typing allows you to add types incrementally. The choice depends on how much time you have and how stable the codebase is.

Q: How do I handle third-party libraries without type stubs?
A: Write minimal stubs for the functions you use, or use Any as a temporary escape hatch. Over time, contribute stubs back to the community. Some tools (e.g., mypy --install-types) can automatically fetch stubs.

Q: What is the difference between any and unknown in TypeScript?
A: any disables all type checking on a value — you can call any method on it. unknown is the type-safe counterpart: you must narrow it (e.g., with typeof or type guards) before using it. Prefer unknown over any when you don’t know the type.

Q: Can gradual typing slow down my CI pipeline?
A: Yes, especially with Python’s mypy on large codebases. Mitigate by using incremental mode, caching, or a faster checker like Pyright. In TypeScript, use --noEmit and project references to speed up checks.

Q: How do I convince my team to adopt gradual typing?
A: Start with a pilot on a single module. Measure the reduction in runtime type errors over a quarter. Share those metrics. Also, highlight developer experience improvements: better autocomplete, easier refactoring, and faster onboarding for new team members.

Next Steps: A Practical Action Plan

If you are ready to introduce gradual typing to your codebase, here are three concrete actions to take this week:

  1. Run a type checker on your codebase today. Install mypy (Python) or enable strict mode in tsconfig.json (TypeScript). Count the errors and share the report with your team. This creates a baseline and sparks discussion.
  2. Define a minimal typing policy. Agree on one rule, e.g., “all new functions must have typed parameters and return values.” Enforce it with a linter or CI check. Keep the policy small so it is easy to follow.
  3. Annotate one critical module. Pick a module that handles external input or is a dependency for many other modules. Fully annotate it and run the checker in strict mode. Fix the errors you find. This demonstrates the value of typing in a controlled experiment.

Gradual typing is not a silver bullet, but when applied thoughtfully, it can significantly reduce production incidents and improve developer productivity. The key is to start small, measure results, and iterate. Over time, you will find the right balance between safety and speed for your team.

Share this article:

Comments (0)

No comments yet. Be the first to comment!