type-assert
pytest plugin that checks a value's static type and its runtime type in one assertion.
A type checker only ever sees the annotations. A runtime checker only ever sees the
values. Either can be right while the other is wrong, and overloaded signatures are
where they drift apart. type-assert pins both halves at once, from one line:
assert_types(json.loads('[1]'), Any)
assert_types(sorted({'b', 'a'}), list[str])
Each line becomes two tests. One runs the expression and checks the value it produced. The other checks what a type checker inferred for the same line. The line only passes if the two agree.
Warning — The API of this package is unstable and likely to change between minor versions (for example
0.1.0to0.2.0). Pin the exact version you depend on, for exampletype-assert==0.1.0.
Installation
pip install type-assert[mypy] # or [pyright], [pyrefly], or [all]
The checker itself is an extra, because it should be whichever one your project already uses.
Usage
Put a directory of case files somewhere in your test tree and point the plugin at it:
[tool.pytest.ini_options]
type_assert_cases = 'tests/typing/cases'
Every .py file under that directory is a case file, subdirectories included, so cases
can be grouped the way the package they exercise is.
A case file is an ordinary Python module. Every top-level assert_types call is a case;
everything else — imports, helpers, constants — is setup shared by the cases in that
file:
from __future__ import annotations
import json
from typing import Any
from type_assert import assert_types
def payload() -> str:
"""Return a document to parse."""
return '{"a": 1}'
assert_types(json.loads(payload()), Any)
assert_types(sorted({'b', 'a'}), list[str])
assert_types(''.join([]), str)
Running pytest collects each case file as a test file of its own:
tests/typing/cases/basics.py::setup
tests/typing/cases/basics.py::sorted({'b', 'a'}) -> list[str] [runtime]
tests/typing/cases/basics.py::sorted({'b', 'a'}) -> list[str] [static]
How assert_types does both
To a type checker, assert_types is
typing_extensions.assert_type,
aliased under TYPE_CHECKING. Checkers resolve an aliased import back to its original
definition, so the special case still applies: the inferred type must match the second
argument exactly, and a supertype is a failure rather than a pass.
At runtime that name is bound to a real checker instead, backed by
pycroscope, which walks containers exhaustively —
it catches a None at any position in a list[int], not only the first element.
Writing the type once covers both halves, and there is no way for them to drift apart.
What each half checks
The checker's assert_type is exact: the inferred type must be the expected type, so
object fails for an int and list[float] fails for a list[int]. The runtime
check is assignability, the relation the type system itself uses for a value: an
instance of a subclass passes for its base class, and every element of a container is
held to the same rule.
Three things are read more strictly than the type system would, because they are where a declared type and a produced value drift apart in practice:
- A number has to be an instance of the numeric class named. An
intdoes not pass forfloat, abooldoes not pass forint, and[1, 2]does not pass forlist[float]. A function that really returns either should sayfloat | int. - A NumPy array is checked as the array type it actually is, dtype and number of
dimensions included, so a
float64array does not pass forNDArray[np.int64]and a 1-D array does not pass forndarray[tuple[int, int], ...]. - A container is walked item by item, a class of your own as much as a built-in one. A
class reaches its item type through the arguments it passes to the
Sequence,SetorMappingit derives from, soBlocks[Polygon]rejects aBlocksholding aCircleexactly aslist[Polygon]would.
All three apply at any depth. The last one is followed through a base class of your own
and through whichever parameter the class actually passes down, so a class whose item
type is its second argument is read correctly rather than assumed. An Iterator is
never walked, since reading it would consume the value the case is about.
What the runtime half still cannot check is a type argument no value carries: a
Result[int] wrapping a single value is only a Result at runtime, and an empty
container agrees with every item type. There the checker is the sole authority. A
supertype still fails only the static half, which is the intended division of labour:
the checker guards what was declared, the run guards what was produced, and a case
passes only when the declaration is exact and the value honours it.
Types that only a checker can spell
Some types have no runtime spelling: a name imported under TYPE_CHECKING, or a class
a checker treats as generic that cannot be subscripted at runtime, such as
np.dtype[np.generic[object]]. Write the type as a string, the way an annotation can be
quoted:
assert_types(dtype_of(array), 'np.dtype[np.generic[object]]')
The checker reads the string as the type it names and holds the case to it exactly. At runtime the string is evaluated in the case file's namespace. When that succeeds the value is checked against it as usual, so a wrong quoted type still fails both halves. When the type cannot be built, the runtime half is skipped with the reason, since there is nothing to check the value against.
To keep a runtime check as well, name the type under TYPE_CHECKING and give it a
runtime stand-in:
if TYPE_CHECKING:
DType = np.dtype[np.generic[object]]
else:
DType = np.dtype
assert_types(dtype_of(array), DType)
The checker still sees the exact type; the value is checked against the stand-in.
Choosing a checker
[tool.pytest.ini_options]
type_assert_checkers = 'mypy' # the default
type_assert_checkers = 'pyright'
type_assert_checkers = 'mypy pyright pyrefly' # each with its own test
Naming more than one gives every case a static test per checker, so a case has to hold under all of them:
cases/basics.py::sorted({'b', 'a'}) -> list[str] [runtime]
cases/basics.py::sorted({'b', 'a'}) -> list[str] [static: mypy]
cases/basics.py::sorted({'b', 'a'}) -> list[str] [static: pyright]
The runtime test is not repeated, since the value does not depend on who checked it. Bear in mind that two checkers do not always infer the same type for the same expression, so a case that satisfies one may need rewording to satisfy both.
ty is deliberately not supported yet: it is pre-1.0 and its output format is still
moving. Adding a backend is a single module — see type_assert/_checkers/.
Skipping a case at runtime
A case that cannot run everywhere — it crashes on a platform, or needs something that is
not always installed — goes in a skip_runtime block. A case that expects Never does
not belong here: its expression raising is the claim it makes, and the runtime half
checks it.
from type_assert import assert_types, skip_runtime
with skip_runtime(reason='why running it fails here'):
assert_types(an_expression(), int)
assert_types(another_expression(), str)
with skip_runtime(sys.platform == 'win32', reason='why it fails on Windows'):
assert_types(a_third_expression(), bytes)
Only the runtime half is skipped; the checker still checks each case. The arguments are evaluated after the file's setup has run. A block holds only cases and binds no name.
A case that never runs leaves its lines uncovered. This coverage pattern excludes a block without a condition:
[tool.coverage.report]
exclude_also = ['^with skip_runtime\(\s*reason=']
A case that never returns
A case can claim that a call raises, by expecting Never:
assert_types(pointset().slice(), Never)
To a checker that expression ends the module: everything after it is unreachable, and
mypy and pyright check none of it, so a wrong case further down would pass. The plugin
therefore hands each checker the file with every case guarded, which keeps each one
reachable without changing its scope or its line number, so Never cases can sit
anywhere and in any number.
The runtime half runs the expression on its own and passes when it does not return,
so the case is a real assertion rather than something to skip: an expression that
returns a value fails it, naming what came back. The assertion itself is not run,
because the call raises while its own arguments are being evaluated, before
assert_types is entered. NoReturn is read the same way as Never, and a case
in a skip_runtime block is still skipped. mypy reads the guarded text through --shadow-file. pyright
has no equivalent, so it reads a copy of the cases directory made outside the project:
imports relative to that directory resolve inside the copy, absolute ones resolve from
the project as before, and settings scoped to the directory's path do not reach it.
pyrefly checks unreachable code on its own and reads the files as they are.
Running the cases on their own
Case files collect like any other test file, so a job can run just them:
pytest tests/typing/cases --no-cov
pytest-cov's --no-cov matters when the project sets a coverage threshold in
addopts: the cases exercise only what they call, so --cov-fail-under would fail a
run that is only about types. Runs of the whole suite are unaffected.
License
MIT
Metadata
Release files for type-assert 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| type_assert-0.5.0.tar.gz | 49.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| type_assert-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 81.1 kB
Release files / type_assert-0.5.0.tar.gz
| Download URL | type_assert-0.5.0.tar.gz |
|---|---|
| Size | 49.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
05064df0903adb90fcdbb6574e36c52996caba991d00230834fde622062737ce
|
|
BLAKE2b-256 checksum How to use checksums |
8d86b70d1741c9a584dbf54aa4ec07e5d85e1c01738ff42ce3ad05082cdce72f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / type_assert-0.5.0-py3-none-any.whl
| Download URL | type_assert-0.5.0-py3-none-any.whl |
|---|---|
| Size | 32.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
616d6ea59a9a135c394cc15f939a635f40c4543a5afcef3af81ad5708c5bd98e
|
|
BLAKE2b-256 checksum How to use checksums |
713dd6c972c53012080b91df0a07498ffb4f4a6104039477246ed0d5083ea6d3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency log