Python TypedDict

Python 3.8에서는 dictionary에 타이핑을 추가할 수 있는 TypedDict가 추가되었다. (PEP 589)

from typing import TypedDict

class Student(TypedDict):
	grade: int
	name: str

student: Student = { "grade": 1, "name": "tonynamy" }
student = Student(grade=1, name="tonynamy")
student = Student({ "grade": 1, "name": "tonynamy" })

위와 같이 typing 모듈의 TypedDict를 상속 받는 형태로 타입 정의가 가능하며, Pyright, mypy 등을 이용하여 타입 검사에도 도움을 받을 수 있다. 단, 주의할 점은 TypedDict를 상속받은 클래스는 일반적인 클래스가 아닌, 타입이라는 점이다. 따라서 추가적인 프로퍼티나 함수를 정의할 수 없다.


TypedDict 구현 뜯어보기

구현 코드

inspect 모듈의 getsource 함수를 이용하면 함수/클래스 등의 정의를 확인할 수 있다.

>>> import inspect
	from typing import TypedDict
	print(inspect.getsource(TypedDict))

def TypedDict(typename, fields=None, /, *, total=True, **kwargs):
    """A simple typed namespace. At runtime it is equivalent to a plain dict.

    TypedDict creates a dictionary type such that a type checker will expect all
    instances to have a certain set of keys, where each key is
    associated with a value of a consistent type. This expectation
    is not checked at runtime.

    Usage::

        class Point2D(TypedDict):
            x: int
            y: int
            label: str

        a: Point2D = {'x': 1, 'y': 2, 'label': 'good'}  # OK
        b: Point2D = {'z': 3, 'label': 'bad'}           # Fails type check

        assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first')

    The type info can be accessed via the Point2D.__annotations__ dict, and
    the Point2D.__required_keys__ and Point2D.__optional_keys__ frozensets.
    TypedDict supports an additional equivalent form::

        Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str})

    By default, all keys must be present in a TypedDict. It is possible
    to override this by specifying totality::

        class Point2D(TypedDict, total=False):
            x: int
            y: int

    This means that a Point2D TypedDict can have any of the keys omitted. A type
    checker is only expected to support a literal False or True as the value of
    the total argument. True is the default, and makes all items defined in the
    class body be required.

    The Required and NotRequired special forms can also be used to mark
    individual keys as being required or not required::

        class Point2D(TypedDict):
            x: int               # the "x" key must always be present (Required is the default)
            y: NotRequired[int]  # the "y" key can be omitted

    See PEP 655 for more details on Required and NotRequired.
    """
    if fields is None:
        fields = kwargs
    elif kwargs:
        raise TypeError("TypedDict takes either a dict or keyword arguments,"
                        " but not both")
    if kwargs:
        warnings.warn(
            "The kwargs-based syntax for TypedDict definitions is deprecated "
            "in Python 3.11, will be removed in Python 3.13, and may not be "
            "understood by third-party type checkers.",
            DeprecationWarning,
            stacklevel=2,
        )

    ns = {'__annotations__': dict(fields)}
    module = _caller()
    if module is not None:
        # Setting correct module is necessary to make typed dict classes pickleable.
        ns['__module__'] = module

    return _TypedDictMeta(typename, (), ns, total=total)

python 3.11.4 기준 TypedDict는 위와 같이 정의되어 있다.

주요 attribute

The type info can be accessed via the Point2D.__annotations__ dict, and
the Point2D.__required_keys__ and Point2D.__optional_keys__ frozensets.
TypedDict supports an additional equivalent form::

위 주석으로 미루어보아, TypedDict__annotations__, __required_keys____optional_keys__에 접근할 수 있음을 알 수 있다.


{'grade': <class 'int'>, 'name': <class 'str'>}
>>> Student.__required_keys__
frozenset({'name', 'grade'})
>>> Student.__optional_keys__
frozenset()

앞서 정의한 Student__annotations__, __required_keys____optional_keys__는 위와 같다. 변수명 그대로, __annotations__는 TypedDict의 각 필드 이름: 타입 dictionary를 반환하고, required_key, optional_key는 각각 required인 key와 optional한 key의 frozenset을 반환한다.

Optional Key

Optional key는 typing.NotRequired를 이용하여 아래와 같이 정의할 수 있다.

import datetime
from typing import NotRequired, TypedDict

class Student(TypedDict):
	grade: int
	name: str
	graduated_at: NotRequired[datetime.datetime]

student = Student({ # OK
	"grade": 1,
	"name": "tonynamy",
	"graduated_at": datetime.datetime(2023, 2, 14)
})
student = Student({ # Also OK
	"grade": 1,
	"name": "tonynamy",
})

위 예제에서, __required_keys____optional_keys__를 출력해보면 아래와 같이 나온다.

>>> Student.__required_keys__
frozenset({'name', 'grade'})
>>> Student.__optional_keys__
frozenset({'graduated_at'})

Totality

TypedDict 정의 시 total이라는 kwarg를 전달할 수 있다. TypedDict 정의에서 보면, def TypedDicttotal kwarg(default는 True임)를 _TypedDictMeta 에 전달하는 것을 볼 수 있다. 아까와 동일한 방법으로 _TypedDictMeta의 정의를 살펴보자.

>>> import inspect
	from typing import _TypedDictMeta
	print(inspect.getsource(_TypedDictMeta))

class _TypedDictMeta(type):
    def __new__(cls, name, bases, ns, total=True):
        """Create a new typed dict class object.

        This method is called when TypedDict is subclassed,
        or when TypedDict is instantiated. This way
        TypedDict supports all three syntax forms described in its docstring.
        Subclasses and instances of TypedDict return actual dictionaries.
        """
        for base in bases:
            if type(base) is not _TypedDictMeta and base is not Generic:
                raise TypeError('cannot inherit from both a TypedDict type '
                                'and a non-TypedDict base class')

        if any(issubclass(b, Generic) for b in bases):
            generic_base = (Generic,)
        else:
            generic_base = ()

        tp_dict = type.__new__(_TypedDictMeta, name, (*generic_base, dict), ns)

        annotations = {}
        own_annotations = ns.get('__annotations__', {})
        msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type"
        own_annotations = {
            n: _type_check(tp, msg, module=tp_dict.__module__)
            for n, tp in own_annotations.items()
        }
        required_keys = set()
        optional_keys = set()

        for base in bases:
            annotations.update(base.__dict__.get('__annotations__', {}))
            required_keys.update(base.__dict__.get('__required_keys__', ()))
            optional_keys.update(base.__dict__.get('__optional_keys__', ()))

        annotations.update(own_annotations)
        for annotation_key, annotation_type in own_annotations.items():
            annotation_origin = get_origin(annotation_type)
            if annotation_origin is Annotated:
                annotation_args = get_args(annotation_type)
                if annotation_args:
                    annotation_type = annotation_args[0]
                    annotation_origin = get_origin(annotation_type)

            if annotation_origin is Required:
                required_keys.add(annotation_key)
            elif annotation_origin is NotRequired:
                optional_keys.add(annotation_key)
            elif total:
                required_keys.add(annotation_key)
            else:
                optional_keys.add(annotation_key)

        tp_dict.__annotations__ = annotations
        tp_dict.__required_keys__ = frozenset(required_keys)
        tp_dict.__optional_keys__ = frozenset(optional_keys)
        if not hasattr(tp_dict, '__total__'):
            tp_dict.__total__ = total
        return tp_dict

    __call__ = dict  # static method

    def __subclasscheck__(cls, other):
        # Typed dicts are only for static structural subtyping.
        raise TypeError('TypedDict does not support instance and class checks')

    __instancecheck__ = __subclasscheck__

코드를 잘 살펴보면 key의 Required 여부를 판단하는 아래 조건문이 보인다.

if annotation_origin is Required:
	required_keys.add(annotation_key)
elif annotation_origin is NotRequired:
	optional_keys.add(annotation_key)
elif total:
	required_keys.add(annotation_key)
else:
	optional_keys.add(annotation_key)

annotation_origin은 타입의 origin 타입을 말하는 것으로, Required[str]의 경우에는 Required, NotRequired[str]의 경우에는 NotRequired가 이에 해당한다. 즉 key의 required 여부 판단은 아래의 우선 순위를 따른다.

  1. Required로 감싸진 경우 Required
  2. NotRequired로 감싸진 경우 Optional
  3. (Required 혹은 NotRequired로 감싸져 있지 않고) total=True인 경우 Required
  4. (Required 혹은 NotRequired로 감싸져 있지 않고) total=False인 경우 Optional

TypedDicttotal kwarg가 default value로 True를 가지므로, TypedDict에서 타입은 기본적으로 Required임을 알 수 있다. 실제로, total kwarg를 테스트해보면 아래와 같이 출력된다.

# `total=True`
>>> class Student(TypedDict, total=True):
...     grade: int
...     name: str
...
>>> Student.__required_keys__
frozenset({'name', 'grade'})
>>> Student.__optional_keys__
frozenset()

# `total=False`
>>> class Student(TypedDict, total=False):
...     grade: int
...     name: str
...
>>> Student.__required_keys__
frozenset()
>>> Student.__optional_keys__
frozenset({'name', 'grade'})

total=False의 경우 모든 키들이 Optional이 된 것을 볼 수 있다. total=FalseTypedDict에서 키를 Required하게 바꾸려면 total=True일 때와 비슷하게 typing.Required를 사용해 감싸주어야 한다.

>>> from typing import Required
>>> class Student(TypedDict, total=False):
...     grade: Required[int]
...     name: str
>>> Student.__required_keys__