functools.cache (3.9) is lru_cache(maxsize=None). Both memoise on the
argument tuple. Both are the correct answer for pure, hot, small-domain
functions, and both do something to your types that almost nobody knows about.
The signature is gone
Take a fully annotated function and cache it:
from functools import cache
@cache
def fib(n: int) -> int:
return n if n < 2 else fib(n - 1) + fib(n - 2)
Under mypy --strict, all three of these pass:
fib("nope")
fib(1, 2, 3)
fib()
Only the return type survives. typeshed types the cached wrapper’s call as
__call__(*args: Hashable, **kwargs: Hashable) -> _T — it has to, because
lru_cache predates ParamSpec and its wrapper genuinely accepts anything
hashable at runtime. The parameter types are simply not carried through.
This is a typeshed limitation rather than a mypy bug, so pyright is affected identically. There is no checker configuration that fixes it.
The practical consequence is that decorating a function with @cache quietly
removes every call site of that function from static checking. On a widely
called helper that is a large hole, and it opens without a diff to any call
site and without a single warning.
💡If the parameter types are erased, why does the *return* type click to reveal
survive?
Because the return type is carried by the class rather than by the call.
lru_cache returns a _lru_cache_wrapper[_T], generic in exactly one
parameter — the return type — which is solved from the decorated function.
__call__ on that class is then declared to return _T.
Expressing the parameters would need the wrapper to be generic over a
ParamSpec as well, and to declare __call__(*args: P.args, **kwargs: P.kwargs).
That is expressible today, but it would be a lie in the other direction: the
real wrapper additionally requires every argument to be hashable, and
there is no way to say “the same signature, but with a Hashable bound on every
parameter”.
So typeshed chose the version that never rejects a legal call, at the cost of never rejecting an illegal one. Knowing why is what tells you the fix has to live in your code, not in a future stub release.
The other three hazards
Unhashable arguments raise at call time. f([1, 2]) on a cached function
is TypeError: unhashable type: 'list', thrown from inside the decorator with
a traceback that points at functools. The checker cannot see it — a list
argument is not even an error under the erased signature.
@cache is unbounded by design. Every distinct argument tuple is retained
for the life of the process. That is correct for fib; it is a leak for
anything keyed on user input, request IDs, or file paths. Use
lru_cache(maxsize=N) when the domain is not small and closed.
A cached mutable return is shared. If the function returns a list or a
dict, every caller gets the same object, and one caller’s .append is
visible to all the others. Cache immutable returns, or return a copy — and be
aware that the copy costs you most of what the cache saved.
The pattern that fixes all of it
Keep the cached function private and unvalidated, and expose a public wrapper that types and validates the boundary:
@cache
def _distance(a: str, b: str) -> int:
...
def edit_distance(a: object, b: object) -> int:
if not isinstance(a, str) or not isinstance(b, str):
raise TypeError("edit_distance() takes two str arguments")
return _distance(a, b)
The public function has a real signature that mypy checks at every call site.
The isinstance guard converts an unhashable or wrong-typed argument into an
error that names your function. And the private name signals that the
cache’s unbounded growth is a decision made in one place.
💡edit_distance takes object, not str. Doesn't that throw
click to reveal
away the static checking you just said you wanted?
It looks that way and it does not, because object here is deliberate:
this function is a boundary. Its job is to accept whatever the outside
world sends and either narrow it or reject it. Inside, _distance(a, b) is
called with values mypy has proven to be str, so the interesting part of the
program stays fully checked.
If the caller is your own code and the values are already known to be strings,
annotate the parameters str instead — then the checker rejects bad calls
statically and the isinstance guard becomes defence in depth for the
untyped edges (JSON, a web form, a **kwargs splat).
The general rule: object at the boundary plus narrowing, precise types
everywhere behind it. Where you must not use object is in the middle of a
call chain, where it just propagates ignorance.