mrkeyoor.com_
Thu 06 Aug 07:43 UTC
PyPIUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5The function list has barely moved since 0.4 and no release has renamed or removed a helper, so upgrades are quiet. The 4 rather than 5 is because behavior drifts underneath the same names as CPython changes typing internals, and two documented functions now raise ValueError instead of working.
Docs2/5The README is a bullet list of one-line function summaries plus a single example, and it points you at docstrings for anything more. Nothing documents which Python versions each helper is correct on, which is the only question that matters here.
Maintenance2/5No PyPI release since 0.9.0 in May 2023, while merged commits through January 2026 add Python 3.14 support and fix union origins; 22 issues and 2 PRs are open. The fixes exist, they are just not installable, and the project is still classified Alpha after nine years.
Ecosystem3/520.6M weekly downloads is almost entirely transitive through libcst, dataclasses-json, and similar packages that were written before typing.get_origin existed. New projects generally reach for typing_extensions or pydantic instead, so the direct-use community is small.

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
Skip it if

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)            # None

This 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)          # None

A 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 tp

NewType 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 parameter

get_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

PackageRegistryPick it when
typing-extensionsPyPIYou want get_origin, get_args, and newer typing features backported and actively tracked against each Python release.
beartypePyPIThe real goal is checking values against annotations at runtime rather than inspecting the annotations yourself.
typeguardPyPIYou want decorator and import-hook based runtime checking with detailed failure messages.
pydanticPyPIYou are building validation or serialization from annotations and would rather use a finished framework.