Use reveal_type() as a Discipline, Not a One-Off Habit in mypy

Every mypy user has used reveal_type() at some point to answer a quick question about what type a variable holds. Then they deleted it and moved on. That instinct — treat it as temporary scaffolding, rip it out when done — is the thing worth reconsidering. reveal_type() is not just a debug shortcut. Used systematically, it is one of the most precise diagnostic instruments in your static analysis toolkit.

This article is about changing how you reach for reveal_type(). Rather than treating it as a throwaway tool you delete immediately, the argument here is that it should be part of how you reason about and document your type system during development — applied at consistent, strategic points, and removed only after the type logic is confirmed and the annotation is permanent. The difference between using it once in a panic and using it as a discipline is the difference between surviving your type system and understanding it.

What reveal_type() is and how it works in mypy

reveal_type() is a special expression recognized by mypy during static analysis. You wrap any expression in it, run mypy, and mypy emits a note telling you the inferred type of that expression at that exact point in the control flow. It does not exist in the Python runtime — at least, not in a form that does what you might expect.

The mypy documentation states plainly that reveal_type and reveal_locals are "handled specially by mypy during type checking, and don't have to be defined or imported." If you actually run the file as a Python script with reveal_type() left in place, Python raises a NameError unless you have explicitly imported it. That is a critical distinction. The function is a compile-time signal to mypy, not a runtime call.

Here is the most basic usage:

python
i = 1
reveal_type(i)   # mypy output: Revealed type is "builtins.int"

l = [1, 2]
reveal_type(l)   # mypy output: Revealed type is "builtins.list[builtins.int]"

Mypy infers the type from the right-hand side of the assignment and reports it. The output appears as an error-level note in mypy's output, which means it shows up whether or not you have any actual type errors on that run. That phrasing — "error: Revealed type is" — is a historical artifact of how mypy surfaces notes; it does not mean your code is broken.

Note

As of Python 3.11, reveal_type was added to the standard typing module, meaning you can import it and use it safely at runtime. On Python 3.10 and earlier, you can import it from typing_extensions (version 4.2 or later), where it prints the runtime type to stderr and returns the object unchanged. Both paths let you leave reveal_type() calls in code that actually runs — but for most development workflows, the mypy-only pattern is still the default.

There is one important behavioral detail that shapes everything else in this article: mypy will always output Any inside unchecked functions. According to the mypy documentation on type inference, "mypy will not use type inference in dynamically typed functions (those without a function type annotation) — every local variable type defaults to Any in such functions." If you call reveal_type() inside a function that has no type annotations on its parameters or return, every result will be Any, and that result is not telling you something interesting about inference — it is telling you that mypy has switched off. That is information, but you have to know that is what it means.

The problem with using it as a one-off

The typical pattern goes like this: something is not type-checking the way you expect, you drop in a reveal_type(), you read the output, you fix the annotation or the inference issue, and you delete the call. The problem is not the debugging use — that is perfectly reasonable. The problem is that using reveal_type() only in response to errors means you are always working reactively. You only look at what types mypy has inferred after something breaks. You never systematically audit whether the inferences are correct before they cause a problem downstream.

This is especially costly in three situations that keep recurring in typed Python codebases:

First, Any propagation. The mypy documentation on dynamic typing is unambiguous: "Any types may propagate through your program, making type checking less effective, unless you are careful. Function parameters without annotations are also implicitly Any." If a function receives an untyped value and passes it to another function, and that function passes it further, the Any spreads silently. You will not see errors — mypy lets you do anything with an Any-typed value — but you also get no protection. A one-off reveal_type() call at the point where a bug surfaces will tell you the type is Any, but it will not tell you where the Any entered the chain.

Second, generic type arguments. When you use a bare container like list instead of list[int], mypy silently treats the missing parameter as Any. The mypy documentation notes that "generic types missing type parameters will have those parameters implicitly treated as Any." A function that takes a list and operates on its contents can look fully typed while providing no actual guarantees about element types.

Third, type narrowing across branches. Mypy narrows types when it can prove something about them — after an isinstance() check, after a is not None guard, after a custom TypeGuard function returns. The narrowed type is only in effect within that branch. If you have a complex chain of conditions, the type that mypy sees at any given point may be quite different from what you expect. The only way to confirm the narrowed type is to observe it at that exact location in the control flow.

"Adding reveal_type anywhere will cause Mypy to display the inferred type of a variable when type-checking the file. This is very, very, very useful." — Charlie Marsh, Using Mypy in production at Spring

That enthusiasm from a practitioner managing over 300,000 lines of fully typed Python is not accidental. It reflects a workflow where reveal_type() is not a panic button but a standard instrument in the type analysis process.

Where to apply it as a discipline: four systematic uses

1. Auditing Any propagation at module boundaries

When integrating with a third-party library that lacks type stubs, or when passing values across a module boundary where one side is typed and the other is not, place reveal_type() on the value at the point of entry. This is not a debugging step — it is a verification step. You are confirming that the value arriving at your typed code is what you believe it to be, not an Any that will silently defeat your type guards downstream.

python
from some_untyped_library import fetch_records

records = fetch_records()
reveal_type(records)
# If mypy outputs: Revealed type is "Any"
# You need a cast or explicit annotation before using records downstream.

The discipline here is to place this call at the start of any new integration with an untyped dependency, before writing any logic that depends on the type, not after something breaks. When mypy reports Any, you know to add an explicit cast or annotation. When it reports the expected concrete type, you can proceed with confidence.

2. Verifying type narrowing in conditional branches

Mypy's narrowing is contextual and sometimes counter-intuitive. After a type guard, after an isinstance() check, inside a branch of a union type — the inferred type can be quite specific. And if the narrowing does not work the way you assumed, the error will surface somewhere else, often much later. Placing reveal_type() after a narrowing construct confirms that mypy has registered the narrowing correctly.

python
from typing import Union

def process(value: Union[str, int]) -> None:
    if isinstance(value, str):
        reveal_type(value)   # Expected: Revealed type is "builtins.str"
        # If mypy shows "str | int" here, the narrowing has failed somehow.
    else:
        reveal_type(value)   # Expected: Revealed type is "builtins.int"

Mypy's documentation on type narrowing shows this pattern used extensively — including inside TypeGuard functions, inside tuple element checks, and in branches guarded by custom validators. In each case, reveal_type() is used to confirm that the narrowed type matches the expected result. If the narrowing is not working, the reveal_type() output will show the wider union type, and you will know to investigate rather than assuming the guard is in effect.

3. Checking generic parameters during refactoring

Generic types are where inference surprises accumulate fastest. When you refactor a function that accepts or returns a generic container, the downstream inferred types can shift in ways that are hard to track without explicit checkpoints. During a refactor involving generics, place reveal_type() calls at the callsites immediately after changing a signature, before running the full test suite.

python
from typing import TypeVar, Sequence

T = TypeVar("T")

def first(items: Sequence[T]) -> T:
    return items[0]

# After refactoring, verify the generic is being resolved correctly:
result_str = first(["a", "b", "c"])
reveal_type(result_str)   # Revealed type is "builtins.str"

result_int = first([1, 2, 3])
reveal_type(result_int)   # Revealed type is "builtins.int"

This pattern reflects how the mypy team itself documents generic functions in its blog posts and changelog. For example, when mypy 1.11 introduced Python 3.12 generic syntax support, the release notes showed reveal_type() calls placed at function callsites to demonstrate that the generic was being resolved to the correct concrete type. That is a documentation function, not a debugging function.

4. Type-checking inline as documentation

This is the least obvious use, and the one that requires the most discipline. During active development of a new typed function or class, you can use reveal_type() calls as inline assertions about what you expect mypy to infer. When you write the annotation and the body, you add a reveal_type() at the point where each significant intermediate value is computed. You run mypy. If all the revealed types match your intent, the annotations are correct. You then remove the calls — but you have demonstrated that the type logic works at every step, not just at the output.

This is conceptually similar to how the Python typing documentation describes using the mypy API in tests: "In combination with reveal_type, this can be used to write a function which gets the reveal_type output from an expression" — meaning the output is machine-checkable, not just human-readable. For teams with formal type testing needs, the pytest-mypy-plugins package formalizes exactly this pattern, allowing reveal_type assertions to be written in YAML test cases that run through mypy and assert the output matches.

reveal_locals() and when to reach for it instead

reveal_locals() is the companion function. Rather than revealing the type of a single expression, it reveals the inferred types of all local variables at the point where it is called. The Real Python guide to type checking describes its output like this: after calling reveal_locals() at the bottom of a function, mypy emits the revealed type for every local — circumference, radius, and any other names in scope.

python
import math

def compute_area(radius: float) -> float:
    pi = math.pi
    area = pi * radius ** 2
    reveal_locals()
    # mypy output:
    # Revealed local types are:
    #   pi: builtins.float
    #   area: builtins.float
    #   radius: builtins.float
    return area

The right tool to reach for depends on the problem. reveal_type(expr) is precise — you are checking one specific value at one specific control-flow position. Use it for targeted verification of narrowing, generic resolution, or integration point types. reveal_locals() is broad — use it when you want a snapshot of the entire local scope, typically when adding types to a previously untyped function and wanting to see what mypy is inferring for everything at once before committing to annotations.

Pro Tip

When annotating a legacy function for the first time, place reveal_locals() at the end of the function body before adding any annotations. This gives you a complete picture of what mypy infers from scratch — a useful baseline before you start directing it with explicit types.

Keeping it out of production: the pre-commit and linting layer

A reveal_type() call left in production code will cause a NameError at runtime (unless it has been imported from typing or typing_extensions). Even when it is imported and runtime-safe, the output going to stderr in a production process is noise at best and a security disclosure at worst. The discipline of using reveal_type() systematically must be paired with the discipline of guaranteeing its removal.

The recommended approach, documented by Adam Johnson in his guide to reveal_type(), is to configure flake8 — or its equivalent in your linter stack — under pre-commit. Without the typing import in place, flake8 will flag reveal_type() calls with error code F821 (undefined name) and block the commit.

python
# .pre-commit-config.yaml (relevant excerpt)
repos:
  - repo: https://github.com/PyCQA/flake8
    rev: 7.1.2
    hooks:
      - id: flake8

With flake8 running in the pre-commit hook, any reveal_type() call left in a file without the corresponding import from typing will block the commit. This is the safety net that makes systematic use of reveal_type() safe to practice — you can be liberal with its use during development because you know the linter will catch any that slip through.

If your project has moved to ruff as its primary linter (increasingly common in Python projects after 2024), the equivalent rule is F821 in ruff's flake8 compatibility layer. The same pre-commit configuration structure applies.

Warning

If you import reveal_type from typing (Python 3.11+) or typing_extensions, flake8's F821 check will not flag it, because it is now a defined name. In that case, use a custom hook or a regex-based check in your CI pipeline to detect and block reveal_type( calls before they reach main.

The runtime-safe import pattern for Python 3.11+

Starting with Python 3.11, reveal_type was added to the typing module as a proper runtime function. The typing_extensions changelog documents that reveal_type was backported to that package (beginning with version 4.2) for use on earlier Python versions. When called at runtime, the typing_extensions version prints Runtime type is '{typename}' to stderr and returns the object unchanged.

python
# Python 3.11+ — import from typing directly
from typing import reveal_type

x: int = 42
result = reveal_type(x)
# mypy: Revealed type is "builtins.int"
# runtime: prints "Runtime type is 'int'" to stderr, returns 42

# Python 3.10 and earlier — use typing_extensions
from typing_extensions import reveal_type

x: int = 42
result = reveal_type(x)
# Same behavior

This runtime-safe form matters for one specific scenario: test files. If you are writing type-annotated test utilities and want to embed type assertions that run through both mypy and the actual test runner, importing reveal_type lets you do that without wrapping the call in a TYPE_CHECKING guard. The pypi page for typing_extensions confirms that version 4.15.0 (released August 2025) is the current stable release, so the backport is well-maintained and safe to depend on.

Python version Source Runtime behavior
3.11+ from typing import reveal_type Prints runtime type to stderr, returns value unchanged
3.10 and earlier from typing_extensions import reveal_type Same behavior (backport, available since typing_extensions 4.2)
Any version (mypy-only) No import needed NameError if executed — mypy handles it at check time only
Any version (guarded) from typing import TYPE_CHECKING + conditional import Import is invisible at runtime; mypy resolves the type check branch

The TYPE_CHECKING guard is worth mentioning separately. It is a constant that is False at runtime but treated as True by type checkers. Wrapping an import in if TYPE_CHECKING: makes the import visible to mypy but invisible to the Python interpreter. This pattern is already standard for avoiding circular imports in typed codebases. You can use the same pattern to import reveal_type in a way that is technically present for mypy but never executed at runtime — though this is rarely necessary now that the runtime-safe imports from typing and typing_extensions are available.

Key takeaways

  1. Place reveal_type() at integration points proactively, not reactively: When you bring in a value from an untyped library or an untyped module, use reveal_type() to confirm the inferred type before writing any logic that depends on it. An Any result here means your downstream type checks are providing no protection, even if they look correct.
  2. Use it to verify narrowing, not assume it: Type narrowing in mypy is contextual and does not cross certain boundaries (such as inner functions with late-binding closures, or cross-variable conditions). Placing reveal_type() after each narrowing construct during development tells you definitively whether the narrowing is in effect at that point, before you rely on it.
  3. Treat reveal_type() output as a checkpoint during refactoring: When changing a generic signature, place reveal_type() at the callsites after the change and before running tests. If the revealed types shift in unexpected ways, you catch the regression at the type level rather than in test failures.
  4. Use reveal_locals() for broad audits of new or legacy functions: When annotating previously untyped code, a single reveal_locals() call at the end of the function gives you a complete map of what mypy infers from scratch — far faster than adding individual reveal_type() calls for every variable.
  5. Enforce removal automatically: Configure flake8 or ruff in your pre-commit hooks to catch any reveal_type() call that survives into a commit. This is the essential safety net that makes it safe to use reveal_type() liberally during development without risk of it reaching production.

The underlying principle is straightforward: a type system is only as reliable as your confidence in what types mypy has actually inferred, as opposed to what you believe it has inferred. reveal_type() is the tool that closes that gap. Using it at every significant type boundary — not just when something is already broken — is the habit that turns mypy from a linter that catches errors into a type system you can reason about with precision.