Updated: reorganized the post, summarized objections into “known issues”
AFAIK: Currently parameterized types cannot be used directly for type checking in python.
For example:
>>> isinstance(["hello type check"], list[str])
TypeError: isinstance() argument 2 cannot be a parameterized generic
However, recursive type checking seems to be only one step away thanks to the current typing infrastructure. All it takes is a new hook method __type_check__
and a builtin function type_check()
that calls __type_check__
Benefits
Providing a standard type checking interface could maximize compatibility across 3rd party packages.
It will also simplify type checking for small scripts, freeing up the need to install a entire wheel (like cattrr
, pydantic
, beartype
, msgspec
, etc…) just for checking something simple
-
Comparing
type_check()
against current available approaches:# Suppose we have a deserialized object to check. DataType = list[tuple[float, str, float]] data: DataType = deserialize(buffer)
-
type_check
way - one linertype_check(data, DataType)
-
manual type check
def check_data(data) -> bool: for item in data: if not isinstance(data, tuple) or len(data) != 3: return False for el, t in zip(item, (float, str, float)): if not isinstance(el, t): return False return True
-
3rd party wheel (using
cattrs
as an example)Disclaimer
Please forgive me if I did not do this the right way – this is the best I can do with 30 minutes playing with it.
If you think I did not do it properly, then perhaps many others will run into the same situation - and that supports my point that we need a simpler and more standardized way to do type checking.
In addition, this does not meancattrs
is bad - it is GREAT. My point is that they might be too heavy for simple use cases and their behavior does not always align with your expectation (especially if you are new to it).from cattrs import structure def check_data(data) -> bool: # bad: # set([ # (1.0 , "" , False), # (int(1), None, True ), # ]) # will pass validation try: structure(data, DataType) return True except ValueError: return False
Known issues
-
Type checking could be destructive for iterators and generators (thanks to @Nineteendo @mikeshardmind):
This could be mitigated by throwing a TypeError in the builtin type_check implementation for generators.
-
Each classes in the inheritance chain have to correctly implement their own
__type_check__
(thanks again to @mikeshardmind):For now I cannot think of a way to remove this maintenance burden. This is kind of the nature of type variables: you cannot possibly know what to check unless you’re the author of that class…
But this burden seems to reside mostly on package maintainers, not end users. This is because
type_check
will attempt to invoke the nearest parent class in the inheritance chain if the object’s own class does not provide__type_check__
.For example:
class MyArray(np.ndarray[np.float32]): pass # No type check specified arr = MyArray(dtype=np.int64) # bad type_check(arr, MyArray) # hasattr(MyArray, '__type_check__') is False # will invoke: ndarray.__type_check__(arr, np.float32) # Note: this behavior is possible # but not implemented in the demo below.
A demonstrative implementation of type_check()
:
from typing import get_args, get_origin, Union
from types import UnionType
def type_check(obj, t: type) -> bool:
origin, args = get_origin(t), get_args(t)
if origin is None:
# t is not a generic type, fallback to direct type check
return isinstance(obj, t)
if origin is UnionType or origin is Union:
# Union type, check if any of the types match
return any(type_check(obj, arg) for arg in args)
if not isinstance(obj, origin):
# Origin type mismatch
return False
if len(args) == 0:
# No type hint, anything is allowed
return True
if hasattr(origin, "__type_check__"):
# Use t's type checker whenever possible
# e.g.
# class B(A[Generic[T]]): ...
# type_check(B[int](), A[int]) => should give True
return origin.__type_check__(obj, *args)
if hasattr(obj, "__type_check__"):
# Type check supported by this object
return obj.__type_check__(*args)
# Type args specified but type_check not supported by this object
return False
Extending builtin list
to support typecheck:
from builtins import list as builtin_list
from typing import TypeVar
T = TypeVar("T")
class list(builtin_list[T]):
def __type_check__(self, *t: type):
if len(t) == 0:
# No type hint, anything is allowed
return True
elif len(t) == 1:
# Single type hint, all elements must be of this type
typ = t[0]
return all(type_check(el, typ) for el in self)
elif len(t) == len(self):
# Type check each item with corresponding type hint
return all(type_check(el, typ) for el, typ in zip(self, t))
else:
# Number of items mismatches with number of type hints
return False
Usage:
(correctness has been checked on 3.13.0)
# Simple Examples
assert type_check(list([1, 2, 3]) , list[int]) is True
assert type_check(list([1, 2, 3.0]), list[int]) is False # 3rd element type mismatch
# Multiple types
assert type_check(list([1, "2", 3.0]), list[int, str, float]) is True
assert type_check(list([1, "2", "3"]), list[int, str, float]) is False # 3rd element type mismatch
assert type_check(list([1, "2"]) , list[int, str, float]) is False # element count mismatch
# Recursive type checking is automatically supported
assert type_check(list([list([1, 2]), list([3, 4])]), list[list[int]]) is True
assert type_check(list([list([1, 2]), list([3, "4"])]), list[list[int]]) is False # 2nd list fails
# Union types are also supported
L = list[list[int] | list[float]]
assert type_check(list([list([1, 2]), list([3.0, 4.0])]), L) is True
assert type_check(list([list([1, 2.0]), list([3, 4.0])]), L) is False # list[int | float] != list[int] | list[float]
P.S. I expected to find a lot of similar proposals or discussions, but somehow I did not find any similar proposal out there when I did my research. Not sure what’s going on…