Skip to content
← Back to explainers

Python explainer

Accept Types Like str | None as Arguments with TypeForm

About 3 min to read. Allow extra time to work through examples.

Optional. Type a parameter that receives a type such as str | None or list[int], so mypy knows what the result holds, and see what TypeForm leaves to your own code.

This article uses Python. You can make it your practice mode to browse related content across the site.

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

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].

step 25 of 26

Interfaces with Protocols and ABCs

Up next Why Some Classes Cannot Share a Subclass