Skip to content
← All articles

TypeForm: typing the values that are themselves types

Why every validator, deserializer and DI container ends up with a def parse(t: type[T], raw: object) -> T that rejects Optional, and what TypeForm changes.

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 qualifiersFinal, 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.