Python Type Hints - Lambdas don’t support type hints, but that’s okay

A tangle of lambdas (generated by Stable Diffusion)

Python has no syntax to add type hints to lambdas, but that doesn’t mean you can’t use them in type-checked code. In this post we’ll look at how Mypy can infer the types for lambdas, based on where they’re used.

lambda’s don’t support type hints

Take this simple lambda:

double = lambda x: x * 2

double(4501)

It’s intended to take an int and return an int. But check its type with reveal_type():

double = lambda x: x * 2
reveal_type(double)

…and Mypy tells says it takes and returns Any:

$ mypy example.py
example.py:2: note: Revealed type is "def (x: Any) -> Any"
Success: no issues found in 1 source file

You might wonder if you can add type hints to a lambda? Perhaps like:

double = lambda (x: int) -> None: x * 2  # doesn't work

Unfortunately not. There is no supported syntax, so the above is a SyntaxError.

PEP 3107, “Function Annotations”, declared that lambda would not support annotations (type hint syntax). The PEP gives three reasons:

  1. It would be an incompatible change.
  2. Lambdas are neutered anyway.
  3. The lambda can always be changed to a function.

(Lambdas are “neutered” in the sense that they only support a subset of function features, e.g. no keyword-only arguments.)

So, there’s no way to add type hints to a lambda. But that’s normally fine…

Mypy infers types for lambda’s

In most places you’d use a lambda, Mypy can infer the argument type, type check the lambda, and infer the return type. It’s pretty neat!

Take this code:

numbers = [1, 2, 3]
doubled = map(lambda x: x * 2, numbers)
reveal_type(doubled)

Running Mypy, you can see the revealed type of doubled:

$ mypy example.py
example.py:3: note: Revealed type is "builtins.map[builtins.int]"
Success: no issues found in 1 source file

Mypy sees that map() will need a function that takes an int, and uses that for the x argument. Following the expression in the lambda, Mypy can infer its return type is also int. It then follows that doubled will be a map[int] (map() returns the its own lazy iterable type).

If Mypy didn’t do this inference, it would only be able to declare doubled as map[Any]. This would be sad, since further usage would not be type-checked.

Mypy uses the inferred lambda argument types to detect errors in the lambda’s expression. For example, imagine you wanted to use int.bit_length() on each number, but typo’d the name (no _):

numbers = [1, 2, 3]
bit_lengths = map(lambda x: x.bitlength(), numbers)

Mypy could spot this:

$ mypy example.py
example.py:2: error: "int" has no attribute "bitlength"; maybe "bit_length"?
Found 1 error in 1 file (checked 1 source file)

Slick.

Inference only works on the same line

The above inference doesn’t work if the lambda declaration is separate from its usage. For example, if you change the above example to store the lambda in a variable:

numbers = [1, 2, 3]
double = lambda x: x * 2
doubled = map(double, numbers)
reveal_type(doubled)

…then doubled has type map[Any]:

$ mypy example.py
example.py:4: note: Revealed type is "builtins.map[Any]"
Success: no issues found in 1 source file

Mypy acts like this because the lambda could be used in several contexts.

You can fix this by typing the variable that stores the lambda:

from collections.abc import Callable

numbers = [1, 2, 3]
double: Callable[[int], int] = lambda x: x * 2  # not advised
doubled = map(double, numbers)

…but at that point you’re better off writing a function.

PEP 8 also recommends you don’t assign lambdas to variables:

Always use a def statement instead of an assignment statement that binds a lambda expression directly to an identifier

Correspondingly, pycodestyle (run by flake8) has an error code for such assignments:

E731 do not assign a lambda expression, use a def

Fair enough.

Mypy doesn’t have a way to detect assignments of lambdas, exactly. But its disallow_any_expr option will prevent assigning lambdas to untyped variables, since it prevents use of all types containing Any. For example, running with that option on the first example in this section, you’ll see:

$ mypy --disallow-any-expr example.py
example.py:2: error: Expression type contains "Any" (has type "Callable[[Any], Any]")
example.py:2: error: Expression has type "Any"
example.py:3: error: Expression type contains "Any" (has type "map[Any]")
example.py:3: error: Expression type contains "Any" (has type "Callable[[Any], Any]")
Found 4 errors in 1 file (checked 1 source file)

It shows errors for both the assignment of the lambda, and that its use results in doubled being a map[Any].

disallow_any_expr is heavily restrictive though, and thus not normally feasible. It isn’t even included in strict mode.

Inference works in custom functions

Mypy uses its lambda inference in non-builtin functions that take Callable types as well. For example, you could implement a custom version of map like so:

from collections.abc import Callable
from typing import TypeVar

T = TypeVar("T")
R = TypeVar("R")


def eager_map(
    fn: Callable[[T], R],
    items: list[T],
) -> list[R]:
    return [fn(i) for i in items]


numbers = [1, 2, 3]
doubled = eager_map(lambda x: x * 2, numbers)
reveal_type(doubled)

…and Mypy can infer the type of doubled just fine:

$ mypy example.py
example.py:14: note: Revealed type is "builtins.list[builtins.int]"
Success: no issues found in 1 source file

Good good good.

Fin

May your lambda types be always inferred,

—Adam

🐑


Read my book Boost Your Git DX to Git better.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: ,