This article is optional, because it covers a Python 3.15 feature that you need only in a library that takes a type as an argument. You can skip it on a first pass.
Say you are writing a settings library. Each field records its name and the
type of value it holds, and you want mypy to know that type. A first try
uses type[T]:
class Field[T]:
def __init__(self, name: str, kind: type[T]) -> None:
self.name = name
self.kind = kind
port = Field("port", int)
host = Field("host", str | None)
mypy infers Field[int] for port, and rejects host:
error: Argument 2 to "Field" has incompatible type "UnionType | type[str]"; expected "type[str]" [arg-type]
type[T] means a class whose instances are T, and str | None is not a
class. It is a type expression, which is anything you can write as an
annotation, such as int, str | None, list[int] or
Literal["dev", "prod"].
Accept any type expression with TypeForm
TypeForm[T] means a type expression that describes T. Use it in place
of type[T], and each field gets the type you passed:
from typing import Literal, TypeForm, reveal_type
class Field[T]:
def __init__(self, name: str, kind: TypeForm[T]) -> None:
self.name = name
self.kind = kind
port = Field("port", int)
host = Field("host", str | None)
mode = Field("mode", Literal["dev", "prod"])
reveal_type(port) # mypy: Field[int]
reveal_type(host) # mypy: Field[str | None]
reveal_type(mode) # mypy: Field[Literal['dev'] | Literal['prod']]
On Python 3.14 and earlier, import TypeForm from typing_extensions
instead.
Common mistakes
Expecting TypeForm to check values. Field("port", int) tells mypy
that the field holds an int. Nothing checks that the value you load later
is one. At runtime, kind is the object you passed, such as the class
int, a union object for str | None, or a Literal object. Your library
has to inspect it and check or convert each value itself.
typing.get_origin and typing.get_args take a type expression apart.
Forgetting strings. mypy also accepts a type expression written as a
string, so Field("rows", "list[int]") gives Field[list[int]]. At
runtime, though, kind is the string "list[int]". If your library accepts
strings, it has to handle them. Turning a string into a type means
evaluating it, which can run arbitrary code, so do that only with strings
from your own source code.
Passing a qualifier. Final[int] and NotRequired[str] change how a
name or a key behaves. They are not types of values, so mypy rejects
Field("limit", Final[int]).
Check your understanding
A library declares def load[T](raw: object, kind: TypeForm[T]) -> T. What does its body still have to do?
Show answer
Hide answer
It has to check or convert raw so that the result matches kind,
and raise an error when it cannot. TypeForm lets mypy connect kind to
the return type at each call, and nothing more. If the body returns raw
through a cast, mypy accepts it, and callers get whatever the data
happened to be.
Going further
PEP 747 defines TypeForm, and the
typing_extensions documentation
covers the backport for older Python versions. mypy 2.4.0, the version this
site runs, checks every example here with no extra flags.
You can also call TypeForm on a value. At runtime, TypeForm(x) returns
x unchanged. For mypy, the result is a type expression, so
TypeForm(str | None) has the type TypeForm[str | None].