typing-inspect
typing-inspect answers questions about type annotations at runtime. Given something like Optional[List[int]], it tells you whether it is a union, what its members are, what container it wraps, and what that container holds. It exists because typing objects are not normal classes: isinstance and issubclass refuse to work on them, and the private attributes that do hold the answers changed shape in almost every Python release. The module is a stable-ish facade over those internals, used mostly by serializers, dependency injectors, and ORMs that build behavior from annotations. The author still labels the API experimental and the PyPI classifier still says Alpha.
Useful for the handful of predicates the standard library never shipped, and reliable for the classic typing constructs it was written against. Reach for typing.get_origin and typing.get_args first, and treat anything involving PEP 604 unions, Annotated, or TypedDict as needing a test before you rely on it.
Use it if
- You are writing a deserializer or validator that reads dataclass and function annotations and needs to branch on Optional, List, Dict, Literal, and Union
- You need helpers the standard library never added: is_optional_type, is_literal_type, is_new_type, is_typevar, get_bound, and get_constraints have no typing equivalent
- You support Python versions old enough that typing.get_origin and typing.get_args are missing or behave differently, and you want one call that works across the range
- You are inspecting generic user classes: get_generic_type, get_generic_bases, and get_parameters cover Generic subclasses that the stdlib helpers say little about
- You already have it in your dependency tree through libcst or a serialization library and want to use it directly rather than add another
- You are on Python 3.8 or newer and only need origins and arguments: typing.get_origin and typing.get_args are in the standard library, are maintained with the language, and handle cases this package does not
- You use PEP 604 unions: get_origin(int | str) returns None here while typing.get_origin returns types.UnionType, so a dispatch table keyed on origin silently falls through to your default branch
- You use Annotated: get_origin(Annotated[int, 'meta']) returns int and get_args drops the metadata entirely, which means FastAPI-style or Pydantic-style annotations lose exactly the part you wanted to read
- You call typed_dict_keys: it returns None for classes built from typing.TypedDict on current Python and only works with the typing_extensions or mypy_extensions versions, so it fails on the import everyone actually writes
- You want a maintained dependency: 0.9.0 shipped in May 2023 and nothing has been released since, even though fixes for Python 3.14 and for union origins were merged into the repo through January 2026
- You want runtime type checking rather than introspection: beartype and typeguard already do the hard part and you do not need to reimplement it on top of these primitives
Setup reality
pip install typing-inspect is a pure-Python single module that pulls mypy_extensions and typing_extensions, so installation is never the problem. Everything else about it is version-sensitive. The distribution is typing-inspect but the import is typing_inspect. Two documented functions, get_last_origin and get_last_args, raise ValueError on anything newer than Python 3.6, so README examples can fail on your interpreter. There are no type stubs and no py.typed marker, which is an odd gap for a typing utility, so mypy treats the import as untyped unless you ignore it. And because 0.9.0 predates several typing changes, the safe pattern is to test every helper against the exact annotations your codebase writes rather than trusting the function names.
Patterns
Tell whether a field can be Nonedetect-optional
from typing import Optional, get_type_hints
from typing_inspect import is_optional_type, get_args
hints = get_type_hints(MyModel)
field = hints['nickname'] # Optional[str]
is_optional_type(field) # True
inner = [a for a in get_args(field) if a is not type(None)]
# [<class 'str'>]is_optional_type only looks one level deep: List[Optional[int]] is not optional, and a TypeVar bound to Optional[int] is not either. Strip NoneType out of get_args yourself to recover the real type.
Get the container and its element typeunwrap-container
from typing import Dict, List
from typing_inspect import get_origin, get_args
get_origin(List[int]) # <class 'list'>
get_args(List[int]) # (<class 'int'>,)
get_origin(Dict[str, List[int]]) # <class 'dict'>
get_args(Dict[str, List[int]]) # (<class 'str'>, typing.List[int])Arguments come back one level at a time, so nested generics need recursion. The lowercase builtin forms (list[int], dict[str, int]) work the same way on Python 3.9 and later.
Enumerate the members of a unionunion-members
from typing import Union
from typing_inspect import is_union_type, get_args, get_origin
is_union_type(Union[int, str]) # True
get_args(Union[int, str]) # (<class 'int'>, <class 'str'>)
get_origin(Union[int, str]) # typing.Union
is_union_type(int | str) # True
get_args(int | str) # (<class 'int'>, <class 'str'>)
get_origin(int | str) # NoneThis is the sharpest edge in the library. PEP 604 unions answer is_union_type and get_args correctly but return None from get_origin in 0.9.0, so branch on is_union_type rather than on the origin value.
Read the allowed values out of a Literalliteral-values
from typing import Literal
from typing_inspect import is_literal_type, get_args
Status = Literal['open', 'closed', 'merged']
if is_literal_type(Status):
allowed = set(get_args(Status)) # {'open', 'closed', 'merged'}get_args on a Literal returns the values themselves, not types, so do not pass them to isinstance. Nested literals such as Literal[Literal['a'], 'b'] are flattened by typing before you see them.
Read a TypeVar's bound or constraintstypevar-bounds
from typing import TypeVar
from typing_inspect import is_typevar, get_bound, get_constraints
Num = TypeVar('Num', bound=int)
SOrB = TypeVar('SOrB', str, bytes)
is_typevar(Num) # True
get_bound(Num) # <class 'int'>
get_constraints(SOrB) # (<class 'str'>, <class 'bytes'>)
get_bound(SOrB) # NoneA TypeVar has either a bound or constraints, never both, and the unused one comes back as None or an empty tuple. There is no stdlib equivalent for these two, which is a genuine reason to keep the dependency.
See through a NewType wrappernewtype-detection
from typing import NewType
from typing_inspect import is_new_type
UserId = NewType('UserId', int)
is_new_type(UserId) # True
UserId.__supertype__ # <class 'int'>
def underlying(tp):
while is_new_type(tp):
tp = tp.__supertype__
return tpNewType became a real class in Python 3.10, so old code that checked callable(tp) and tp.__supertype__ by hand broke; is_new_type covers both shapes. Chained NewTypes need the loop.
Read the field types of a TypedDicttypeddict-keys
import typing
import typing_extensions
from typing_inspect import typed_dict_keys
class A(typing.TypedDict):
a: int
class B(typing_extensions.TypedDict):
b: str
typed_dict_keys(A) # None <- not what you expected
typed_dict_keys(B) # {'b': <class 'str'>}
# portable alternative:
typing.get_type_hints(A) # {'a': <class 'int'>}On current Python this helper only recognises the typing_extensions and mypy_extensions flavours of TypedDict. Use get_type_hints plus __required_keys__ and __optional_keys__ instead; they work for every flavour.
Handle string annotations that are not resolved yetforward-references
import sys
from typing import ForwardRef, get_type_hints
from typing_inspect import is_forward_ref, get_forward_arg
ref = ForwardRef('Node')
is_forward_ref(ref) # True
get_forward_arg(ref) # 'Node'
# resolve properly once the class exists:
hints = get_type_hints(Node, globalns=vars(sys.modules[Node.__module__]))With from __future__ import annotations every annotation is a plain string, not a ForwardRef, so is_forward_ref returns False on all of them. Call get_type_hints first and inspect what it hands back.
Introspect a user-defined generic classgeneric-classes
from typing import Generic, TypeVar
from typing_inspect import (
is_generic_type, get_origin, get_args, get_parameters, get_generic_type,
)
T = TypeVar('T')
class Box(Generic[T]):
def __init__(self, item: T) -> None:
self.item = item
is_generic_type(Box[int]) # True
get_origin(Box[int]) # <class 'Box'>
get_args(Box[int]) # (<class 'int'>,)
get_parameters(Box) # (~T,)
get_generic_type(Box(1)) # <class 'Box'> plain instances lose the parameterget_parameters returns the still-unbound TypeVars, which is how you tell Box from Box[int]. get_generic_type only recovers Box[int] from instances created through the subscripted class; ordinary Box(1) instances carry nothing. is_generic_type deliberately returns False for Union, Tuple, Callable, and ClassVar.
Do not use it on Annotatedannotated-gotcha
from typing import Annotated, get_origin, get_args
import typing_inspect as ti
Field = Annotated[int, 'max=10']
ti.get_origin(Field) # <class 'int'> metadata already gone
ti.get_args(Field) # (<class 'int'>,) metadata already gone
get_origin(Field) # typing.Annotated
get_args(Field) # (<class 'int'>, 'max=10')Verified on 0.9.0: the typing_inspect helpers unwrap Annotated and throw the metadata away, while the stdlib versions keep it. If your framework carries information in Annotated, use typing.get_args.
Build a coercion table from dataclass annotationsannotation-dispatch
from dataclasses import fields, is_dataclass
from typing import get_type_hints
from typing_inspect import is_optional_type, is_literal_type, get_origin, get_args
def coerce(value, tp):
if is_optional_type(tp):
if value is None:
return None
tp = next(a for a in get_args(tp) if a is not type(None))
if is_literal_type(tp):
if value not in get_args(tp):
raise ValueError(f'{value!r} not allowed')
return value
origin = get_origin(tp)
if origin in (list, set, tuple):
(item_tp,) = get_args(tp)
return origin(coerce(v, item_tp) for v in value)
if is_dataclass(tp):
hints = get_type_hints(tp)
return tp(**{f.name: coerce(value[f.name], hints[f.name]) for f in fields(tp)})
return tp(value)This is the shape of every hand-rolled deserializer built on this library. Once it grows a second branch for unions or Annotated you are rebuilding pydantic, which is the point at which you should stop.
Check whether you need the dependency at allprefer-stdlib
# Python 3.8 and later, no third-party import:
from typing import Optional, List, Union, get_origin, get_args
get_origin(List[int]) # <class 'list'>
get_args(Optional[int]) # (<class 'int'>, <class 'NoneType'>)
get_origin(int | str) # <class 'types.UnionType'>
def is_optional(tp) -> bool:
return get_origin(tp) in (Union, type(int | str)) and type(None) in get_args(tp)If the only calls in your codebase are get_origin and get_args, delete the dependency. Keep typing-inspect only for get_bound, get_constraints, is_new_type, and the other predicates the stdlib never added.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| typing-extensions | PyPI | You want get_origin, get_args, and newer typing features backported and actively tracked against each Python release. |
| beartype | PyPI | The real goal is checking values against annotations at runtime rather than inspecting the annotations yourself. |
| typeguard | PyPI | You want decorator and import-hook based runtime checking with detailed failure messages. |
| pydantic | PyPI | You are building validation or serialization from annotations and would rather use a finished framework. |