Everyone who has written a validator, a deserializer, a settings loader or a dependency-injection container has written this function:
def parse[T](target: type[T], raw: object) -> T: ...
And everyone has then discovered that it rejects half the types they wanted to pass:
parse(int, "3") # fine
parse(int | None, raw) # error
parse(list[int], raw) # error
parse(Literal["a", "b"], raw) # error
The workaround is universal and unpleasant: widen the parameter to object
or Any, lose the connection between the argument and the return type, and
hand the caller back an unchecked value from the one function whose entire
job was to produce a checked one.
Why type[T] is not the right annotation
type[T] means a class object. Its values are things you could call to
construct an instance, and things isinstance accepts as a second argument.
int qualifies. int | None does not — at runtime it is a types.UnionType,
not a class. list[int] is a types.GenericAlias. Literal["a"] is a
typing._LiteralGenericAlias. None of them are classes, and correctly so.
What you actually wanted to say was: a value that is a type expression describing T. That is a different concept, and until PEP 747 the type system had no name for it.
💡Before reading on: why is type[int | None] not a workable spelling of what we want?
click to reveal
Because type[X] distributes over the union: type[int | None] means
type[int] | type[None] — a value that is either the class int or the
class NoneType. That is two separate class objects, not one object
describing the union.
So the annotation is not merely unhelpful, it means something else entirely,
and the value you want to pass (int | None, a single UnionType instance)
is not a member of it.
This is a good instance of a general trap: type[...] is a constructor over
the values of a type, and unions of types are not the same shape as types
of unions.
What TypeForm[T] says
TypeForm[T] (PEP 747, targeting 3.15, available today via
typing_extensions) means: a type form object describing T. Its values
include everything a type expression can evaluate to — classes, unions,
generic aliases, Literals, Callable[...], None, Any, and so on.
The motivating signature from the PEP is worth memorising, because it is the shape of every runtime-validation library:
def isassignable[T](value: object, typx: TypeForm[T]) -> TypeIs[T]: ...
Read it slowly. It takes an unknown value and a type expression, and its
boolean result narrows the value to that type. That is the thing you have
been hand-rolling with cast at the bottom of every deserializer.
Type expressions vs annotation expressions
The distinction PEP 747 makes precise, and which is worth carrying independently of the feature:
A type expression describes a set of values. int, str | None,
list[int], Literal[3], Callable[[int], str].
An annotation expression is anything legal in an annotation position. It
is a superset: it also includes type qualifiers — Final, ClassVar,
Required, NotRequired, ReadOnly, and InitVar.
Qualifiers are excluded from TypeForm. ClassVar[int] does not
describe a set of values; it describes where and how a name is stored.
Required[str] is a statement about a TypedDict item’s presence, not about
strings. There is no coherent T for TypeForm[ClassVar[int]] to mean.
💡Annotated[int, Gt(0)] is legal in an annotation. Is it a type expression, and can it be a TypeForm?
click to reveal
Yes to both — and the reason is precisely why Annotated is different from
the qualifiers.
Annotated[int, Gt(0)] is a type expression: it describes exactly the
same set of values as int. The metadata is transparent to the type system
by design, which is the whole point of Annotated. So it has a well-defined
T — namely int — and TypeForm[int] accepts it.
Final[int] is not a type expression, because Final is not a statement
about which values are permitted; it is a statement about rebinding the name.
You cannot have a list[Final[int]].
The practical test: can you write it as a type argument? list[Annotated[int, Gt(0)]]
is legal. list[Final[int]], list[ClassVar[int]] and list[Required[int]]
are not. If it cannot go inside a generic, it is a qualifier, not a type.
At runtime
TypeForm(x) returns x unchanged. It is an identity function that exists
so the expression is valid at runtime; all the meaning is static. That
matches cast, assert_type and the rest of the typing module’s
static-only surface, and it means adopting TypeForm costs nothing at
import or call time.
💡A validation library adds def validate[T](value: object, typx: TypeForm[T]) -> T. What does the type system now guarantee, and what does it still not?
click to reveal
It guarantees the connection between the two arguments and the return.
Call validate(raw, list[int]) and the checker knows the result is a
list[int]. Call it with str | None and the result is str | None. The
caller stops needing a cast, and the return type stops being Any —
which is the single biggest Any-hole in most deserialization code.
It guarantees nothing about the implementation. Inside validate, typx
is a TypeForm[T] and the body still has to reflect over it at runtime —
get_origin, get_args, isinstance, the works — and the body is exactly
as capable of being wrong as it was before. TypeForm moves the honesty to
the boundary; the risk is still concentrated in one audited function.
That is the same trade as TypeIs: one function carries the danger, and its
signature is a promise the callers can rely on. The improvement is not that
the dangerous code disappeared; it is that there is now exactly one place to
look for it, and a signature that makes reviewing it worthwhile.
Status
PEP 747 targets 3.15. typing_extensions.TypeForm exists now and checkers
are adopting it. If you maintain a validation or configuration library, this
is the annotation to plan for; if you consume one, expect the Any at that
seam to quietly disappear over the next couple of releases.