PEP 705 – TypedDict: 읽기 전용 항목
- Author:
- Alice Purcell <alicederyn at gmail.com>
- Sponsor:
- Pablo Galindo Salgado <pablogsal at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 07-Nov-2022
- Python-Version:
- 3.13
- Post-History:
- 30-Sep-2022, 02-Nov-2022, 14-Mar-2023, 17-Oct-2023, 04-Nov-2023
- Resolution:
- 29-Feb-2024
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
PEP 589는 고정된 키 집합을 가진 딕셔너리를 위한 구조적 타입 TypedDict를 정의합니다. TypedDict는 가변 타입이므로, 읽기 전용 매개변수를 받는 메서드에 유효한 입력을 막지 않으면서 올바르게 어노테이션을 지정하기가 어렵습니다.
이 PEP는 이러한 용도를 지원하기 위해 새로운 타입 한정자 typing.ReadOnly를 제안합니다. 이 PEP는 Python 문법을 변경하지 않습니다. TypedDict의 읽기 전용 키를 올바르게 사용하는지는 정적 타입 검사기만이 강제하도록 의도되었으며, 런타임에 Python 자체가 강제하지는 않습니다.
동기
문자열 키를 갖는 (중첩되었을 수도 있는) 딕셔너리를 사용해 구조화된 데이터를 표현하는 것은 Python 프로그램에서 흔한 패턴입니다. PEP 589는 정확한 타입을 미리 알고 있을 때 이러한 값을 타입 검사할 수 있게 해 주지만, 더 구체적인 변형을 받아들이는 읽기 전용 코드를 작성하기는 어렵습니다. 예를 들어 값이 서브타입일 수 있거나, 가능한 타입들의 유니온을 제한하는 경우가 그렇습니다. 이는 서비스용 API를 작성할 때 특히 흔한 문제입니다. 이러한 API는 매우 다양한 입력 구조를 지원할 수 있으며, 일반적으로 입력을 수정할 필요가 없습니다.
순수 함수
movie_string 함수에 타입 힌트를 추가하려 한다고 생각해 보십시오:
def movie_string(movie: Movie) -> str:
if movie.get("year") is None:
return movie["name"]
else:
return f'{movie["name"]} ({movie["year"]})'
이 Movie 타입은 TypedDict를 사용하여 정의할 수 있습니다:
from typing import NotRequired, TypedDict
class Movie(TypedDict):
name: str
year: NotRequired[int | None]
그런데 year가 필수인 다른 타입이 있다고 가정해 봅시다:
class MovieRecord(TypedDict):
name: str
year: int
MovieRecord를 movie_string에 전달하려고 하면 (mypy 사용 시) 다음 오류가 발생합니다:
Argument 1 to "movie_string" has incompatible type "MovieRecord"; expected "Movie"
이 특정 사용 사례는 타입 안전해야 하지만, 일반적인 경우 타입 검사기가 사용자가 MovieRecord를 Movie 매개변수에 전달하지 못하도록 막는 것은 올바른 동작입니다. Movie 클래스에는 함수가 MovieRecord의 타입 제약을 깨뜨릴 가능성이 있는 변경 메서드가 있기 때문입니다(예: movie["year"] = None 또는 del movie["year"]). Movie에 변경 메서드가 없다면 이 문제는 사라집니다. 이는 PEP 544 Protocol을 사용하여 불변 인터페이스를 정의함으로써 달성할 수 있습니다:
from typing import Literal, Protocol, overload
class Movie(Protocol):
@overload
def get(self, key: Literal["name"]) -> str: ...
@overload
def get(self, key: Literal["year"]) -> int | None: ...
@overload
def __getitem__(self, key: Literal["name"]) -> str: ...
@overload
def __getitem__(self, key: Literal["year"]) -> int | None: ...
이 방식은 매우 반복적이고 실수하기 쉬우며, __contains__()나 keys() 같은 중요한 메서드 정의가 여전히 빠져 있습니다.
중첩된 딕셔너리 갱신하기
TypedDict의 구조적 타이핑은 수정하는 항목의 타입만 제약하는 갱신 함수를 작성할 수 있도록 해야 합니다:
class HasTimestamp(TypedDict):
timestamp: float
class Logs(TypedDict):
timestamp: float
loglines: list[str]
def update_timestamp(d: HasTimestamp) -> None:
d["timestamp"] = now()
def add_logline(logs: Logs, logline: str) -> None:
logs["loglines"].append(logline)
update_timestamp(logs) # Accepted by type checker
그러나 딕셔너리를 중첩하기 시작하면 이 방식은 더 이상 동작하지 않습니다:
class HasTimestampedMetadata(TypedDict):
metadata: HasTimestamp
class UserAudit(TypedDict):
name: str
metadata: Logs
def update_metadata_timestamp(d: HasTimestampedMetadata) -> None:
d["metadata"]["timestamp"] = now()
def rename_user(d: UserAudit, name: str) -> None:
d["name"] = name
update_metadata_timestamp(d) # Type check error: "metadata" is not of type HasTimestamp
이는 오류처럼 보이지만, 단지 HasTimestampedMetadata 인스턴스가 가진 metadata 항목을 더 이상 Logs 인스턴스가 아닐 수도 있는 다른 HasTimestamp 인스턴스로 덮어쓸 수 있는 (원치 않는) 능력 때문입니다.
(Python 3.11부터) 제네릭으로 이 문제를 우회할 수는 있지만, 중첩된 dict마다 타입 매개변수가 필요하므로 매우 복잡합니다.
근거
이러한 문제는 TypedDict의 항목 중 하나 이상을 갱신하는 능력을 제거함으로써 해결할 수 있습니다. 이것이 항목이 불변이라는 의미는 아닙니다. 기반 딕셔너리에 대한 참조가, 해당 항목에 변경 연산이 있는 다르지만 호환되는 타입으로 여전히 존재할 수 있습니다. 이러한 항목은 “읽기 전용”이며, 이를 위해 새로운 typing.ReadOnly 타입 한정자를 도입합니다.
그러면 첫 번째 동기 예제의 movie_string 함수에 다음과 같이 타입을 지정할 수 있습니다:
from typing import NotRequired, ReadOnly, TypedDict
class Movie(TypedDict):
name: ReadOnly[str]
year: ReadOnly[NotRequired[int | None]]
def movie_string(movie: Movie) -> str:
if movie.get("year") is None:
return movie["name"]
else:
return f'{movie["name"]} ({movie["year"]})'
읽기 전용 항목과 읽기 전용이 아닌 항목을 섞어 쓸 수 있으므로, 두 번째 동기 예제에 올바르게 어노테이션을 지정할 수 있습니다:
class HasTimestamp(TypedDict):
timestamp: float
class HasTimestampedMetadata(TypedDict):
metadata: ReadOnly[HasTimestamp]
def update_metadata_timestamp(d: HasTimestampedMetadata) -> None:
d["metadata"]["timestamp"] = now()
class Logs(HasTimestamp):
loglines: list[str]
class UserAudit(TypedDict):
name: str
metadata: Logs
def rename_user(d: UserAudit, name: str) -> None:
d["name"] = name
update_metadata_timestamp(d) # Now OK
이러한 이점 외에도, (읽기 전용 항목을 가진 Movie 같은 TypedDict를 사용하여) 함수의 인자를 읽기 전용으로 표시하면, 함수가 입력을 수정하지 않는다는 사실을 타입 검사기뿐만 아니라 사용자에게도 명시적으로 드러냅니다. 이는 보통 함수 인터페이스에 바람직한 특성입니다.
이 PEP는 ReadOnly를 TypedDict 안에서만 유효하게 만들 것을 제안합니다. 향후 확장으로는 프로토콜 등 다른 맥락에서도 이를 지원하는 방안을 생각할 수 있습니다.
명세
새로운 typing.ReadOnly 타입 한정자가 추가됩니다.
typing.ReadOnly 타입 한정자
typing.ReadOnly 타입 한정자는 TypedDict 정의에서 선언된 항목을 변경(추가, 수정, 삭제)할 수 없음을 나타내는 데 사용됩니다.:
from typing import ReadOnly
class Band(TypedDict):
name: str
members: ReadOnly[list[str]]
blur: Band = {"name": "blur", "members": []}
blur["name"] = "Blur" # OK: "name" is not read-only
blur["members"] = ["Damon Albarn"] # Type check error: "members" is read-only
blur["members"].append("Damon Albarn") # OK: list is mutable
대체 함수형 문법
TypedDict의 대체 함수형 문법도 새 타입 한정자를 지원합니다.:
Band = TypedDict("Band", {"name": str, "members": ReadOnly[list[str]]})
다른 특수 타입과의 상호 작용
ReadOnly[]는 Required[], NotRequired[], Annotated[]와 중첩 순서에 관계없이 함께 사용할 수 있습니다:
class Movie(TypedDict):
title: ReadOnly[Required[str]] # OK
year: ReadOnly[NotRequired[Annotated[int, ValueRange(-9999, 9999)]]] # OK
class Movie(TypedDict):
title: Required[ReadOnly[str]] # OK
year: Annotated[NotRequired[ReadOnly[int]], ValueRange(-9999, 9999)] # OK
이는 PEP 655에서 도입된 동작과 일치합니다.
상속
서브클래스는 읽기 전용 항목을 읽기 전용이 아닌 항목으로 재선언하여 변경할 수 있게 만들 수 있습니다.:
class NamedDict(TypedDict):
name: ReadOnly[str]
class Album(NamedDict):
name: str
year: int
album: Album = { "name": "Flood", "year": 1990 }
album["year"] = 1973
album["name"] = "Dark Side Of The Moon" # OK: "name" is not read-only in Album
읽기 전용 항목을 재선언하지 않으면 그 항목은 읽기 전용으로 유지됩니다.:
class Album(NamedDict):
year: int
album: Album = { "name": "Flood", "year": 1990 }
album["name"] = "Dark Side Of The Moon" # Type check error: "name" is read-only in Album
서브클래스는 읽기 전용 항목의 값 타입을 좁힐 수 있습니다.:
class AlbumCollection(TypedDict):
albums: ReadOnly[Collection[Album]]
class RecordShop(AlbumCollection):
name: str
albums: ReadOnly[list[Album]] # OK: "albums" is read-only in AlbumCollection
서브클래스는 슈퍼클래스에서 읽기 전용이지만 필수가 아닌 항목을 필수로 만들 수 있습니다.:
class OptionalName(TypedDict):
name: ReadOnly[NotRequired[str]]
class RequiredName(OptionalName):
name: ReadOnly[Required[str]]
d: RequiredName = {} # Type check error: "name" required
서브클래스는 이러한 규칙을 조합할 수 있습니다.:
class OptionalIdent(TypedDict):
ident: ReadOnly[NotRequired[str | int]]
class User(OptionalIdent):
ident: str # Required, mutable, and not an int
이는 구조적 타이핑에서 따라 나오는 결과일 뿐이지만, 이제 동작이 PEP 589에 명시된 규칙과 달라지므로 여기서 강조합니다.
타입 일관성
이 절은 이 PEP의 새 기능을 포괄하도록 PEP 589에서 도입된 타입 일관성 규칙을 갱신합니다. 특히 새 기능을 사용하지 않는 모든 타입 쌍은 이미 일관적이었던 경우에(그리고 그 경우에만) 이 새 규칙에서도 일관적입니다.
A가 B와 구조적으로 호환되면 TypedDict 타입 A는 TypedDict B와 일관적입니다. 이는 다음 조건을 모두 만족하는 경우에만 참입니다:
B의 각 항목에 대해,B의 해당 항목이 읽기 전용이고 필수가 아니며 최상위 값 타입(ReadOnly[NotRequired[object]])인 경우가 아니라면A는 대응하는 키를 가집니다.B의 각 항목에 대해,A가 대응하는 키를 가진다면A의 대응하는 값 타입은B의 값 타입과 일관적입니다.B의 읽기 전용이 아닌 각 항목에 대해, 그 값 타입은A의 대응하는 값 타입과 일관적입니다.B의 각 필수 키에 대해, 대응하는 키는A에서도 필수입니다.B의 필수가 아닌 각 키에 대해, 해당 항목이B에서 읽기 전용이 아니라면 대응하는 키는A에서도 필수가 아닙니다.
논의:
- TypedDict에서 명시되지 않은 모든 항목은 암묵적으로 값 타입
ReadOnly[NotRequired[object]]를 가집니다. - 읽기 전용 항목은 변경될 수 없으므로 공변적으로 동작합니다. 이는
Sequence와 같은 컨테이너 타입과 유사하며, 불변적으로 동작하는 읽기 전용이 아닌 항목과는 다릅니다. 예시:class A(TypedDict): x: ReadOnly[int | None] class B(TypedDict): x: int def f(a: A) -> None: print(a["x"] or 0) b: B = {"x": 1} f(b) # Accepted by type checker
- 명시적인 키
'x'가 없는 TypedDict 타입A는 필수가 아닌 키'x'를 가진 TypedDict 타입B와 일관되지 않습니다. 런타임에 키'x'가 존재하면서 호환되지 않는 타입을 가질 수 있기 때문입니다(구조적 서브타이핑으로 인해 이 타입은A를 통해서는 보이지 않을 수 있습니다). 이 규칙의 유일한 예외는B의 항목이 읽기 전용이고 값 타입이 최상위 타입(object)인 경우입니다. 예를 들면:class A(TypedDict): x: int class B(TypedDict): x: int y: ReadOnly[NotRequired[object]] a: A = { "x": 1 } b: B = a # Accepted by type checker
업데이트 메서드
기존 타입 검사 규칙에 더해, 타입 검사기는 읽기 전용 항목이 있는 TypedDict가 해당 키를 선언하는 다른 TypedDict로 업데이트되는 경우 오류를 보고해야 합니다:
class A(TypedDict):
x: ReadOnly[int]
y: int
a1: A = { "x": 1, "y": 2 }
a2: A = { "x": 3, "y": 4 }
a1.update(a2) # Type check error: "x" is read-only in A
단, 선언된 값이 최하위 타입(Never)인 경우는 예외입니다:
class B(TypedDict):
x: NotRequired[typing.Never]
y: ReadOnly[int]
def update_a(a: A, b: B) -> None:
a.update(b) # Accepted by type checker: "x" cannot be set on b
참고: 어떤 것도 Never 타입과 일치하지 않으므로, 이 타입으로 어노테이트된 항목은 반드시 없어야 합니다.
키워드 인자 타이핑
PEP 692는 **kwargs를 TypedDict로 어노테이트하기 위해 Unpack을 도입했습니다. 이런 방식으로 사용되는 TypedDict의 항목 중 하나 이상을 읽기 전용으로 표시해도 메서드의 타입 시그니처에는 아무런 영향이 없습니다. 그러나 함수 본문에서 해당 항목이 수정되는 것은 막습니다:
class Args(TypedDict):
key1: int
key2: str
class ReadOnlyArgs(TypedDict):
key1: ReadOnly[int]
key2: ReadOnly[str]
class Function(Protocol):
def __call__(self, **kwargs: Unpack[Args]) -> None: ...
def impl(**kwargs: Unpack[ReadOnlyArgs]) -> None:
kwargs["key1"] = 3 # Type check error: key1 is readonly
fn: Function = impl # Accepted by type checker: function signatures are identical
런타임 동작
TypedDict 타입에는 __readonly_keys__와 __mutable_keys__라는 두 개의 새로운 속성이 추가되며, 이들은 각각 모든 읽기 전용 키와 읽기 전용이 아닌 키를 담은 frozenset입니다:
class Example(TypedDict):
a: int
b: ReadOnly[int]
c: int
d: ReadOnly[int]
assert Example.__readonly_keys__ == frozenset({'b', 'd'})
assert Example.__mutable_keys__ == frozenset({'a', 'c'})
include_extras가 True가 아닌 한, typing.get_type_hints는 모든 ReadOnly 타입 한정자를 제거합니다:
assert get_type_hints(Example)['b'] == int
assert get_type_hints(Example, include_extras=True)['b'] == ReadOnly[int]
typing.get_origin과 typing.get_args는 ReadOnly를 인식하도록 업데이트됩니다:
assert get_origin(ReadOnly[int]) is ReadOnly
assert get_args(ReadOnly[int]) == (int,)
하위 호환성
이 PEP는 TypedDict에 새로운 기능을 추가하므로, TypedDict 타입을 검사하는 코드는 이 기능을 사용하는 타입을 지원하도록 변경되어야 합니다. 이는 주로 타입 검사기에 영향을 줄 것으로 예상됩니다.
보안 영향
이 PEP로 인해 발생하는 알려진 보안상의 결과는 없습니다.
교육 방법
현재 관행에 맞춘 typing 모듈 문서 변경 제안:
- 나열된 다른 PEP들에 이 PEP를 추가하십시오.
- TypedDict와 이 PEP에 링크된
typing.ReadOnly를 추가하십시오. - TypedDict 항목에 다음 텍스트를 추가하십시오:
ReadOnly 타입 한정자는 TypedDict 정의에 선언된 항목을 읽을 수는 있지만 변경(추가, 수정 또는 제거)할 수는 없음을 나타냅니다. 이는 값의 정확한 타입을 아직 알 수 없어서 값을 수정하면 구조적 서브타입이 깨지는 경우에 유용합니다. 예제 삽입
참조 구현
기각된 대안
TypedMapping 프로토콜 타입
이 PEP의 이전 버전에서는 TypedMapping 프로토콜 타입을 제안했습니다. 이 타입은 읽기 전용 TypedDict와 매우 비슷하게 동작하지만, 런타임 타입이 dict여야 한다는 제약은 없습니다. 그러면 TypedMapping을 상속하여 TypedDict를 정의함으로써 현재 버전의 PEP에서 설명하는 동작을 얻을 수 있었을 것입니다. 이 방안은 더 복잡하지만, 그 추가적인 복잡성을 정당화할 만큼 강력한 사용 사례가 없어 당분간 보류했습니다.
고차 ReadOnly 타입
매개변수에서 변경 메서드를 제거하는 일반화된 고차 타입을 추가할 수도 있습니다. 예를 들면 ReadOnly[MovieRecord]와 같습니다. TypedDict의 경우 이는 슈퍼클래스에서 선언된 항목을 포함한 모든 항목에 ReadOnly를 추가하는 것과 같습니다. 이러한 타입은 자연스럽게 TypedDict 서브클래스뿐 아니라 더 넓은 범위의 타입에 대해서도 정의하고 싶어지며, 중첩된 타입에도 적용되는지, 적용된다면 어떻게 적용되는지에 대한 의문도 제기합니다. 저희는 이 PEP의 범위를 더 좁게 유지하기로 결정했습니다.
타입 이름을 Readonly로 짓기
Read-only는 일반적으로 하이픈으로 연결하며, CamelCase로 변환할 때는 대시로 구분된 단어의 첫 글자를 대문자로 쓰는 것이 일반적인 관례로 보입니다. 이는 Wikipedia에 있는 CamelCase의 정의와도 일치하는 것으로 보입니다. Wikipedia에 따르면 CamelCase는 각 단어의 첫 글자를 대문자로 씁니다. 다만 Python의 예시나 반례, 가급적이면 핵심 Python 라이브러리에서 가져온 예시나 반례를 알려 주시거나, 이 관례에 대한 더 명시적인 지침을 알려 주시면 대단히 감사하겠습니다.
Final 어노테이션 재사용
Final 어노테이션은 제안된 ReadOnly 한정자가 TypedDict 항목에 대해 하는 것처럼 속성이 수정되지 않도록 방지합니다. 그러나 이 어노테이션은 서브클래스에서의 재정의도 방지하는 것으로 문서화되어 있습니다. PEP 591에서 인용하면 다음과 같습니다.
typing.Final타입 한정자는 변수나 속성을 재할당, 재정의 또는 오버라이드해서는 안 된다는 것을 나타내는 데 사용됩니다.
이는 ReadOnly의 의도된 용도와 맞지 않습니다. Final이 상황에 따라 다르게 동작하도록 하여 혼란을 일으키기보다는 새로운 한정자를 도입하기로 했습니다.
readonly 플래그
이 PEP의 이전 버전에서는 TypedDict의 모든 항목이 읽기 전용이 되도록 보장하는 불리언 플래그를 도입했습니다:
class Movie(TypedDict, readonly=True):
name: str
year: NotRequired[int | None]
movie: Movie = { "name": "A Clockwork Orange" }
movie["year"] = 1971 # Type check error: "year" is read-only
그러나 이 플래그는 상속을 도입하자 혼란을 일으켰습니다:
class A(TypedDict):
key1: int
class B(A, TypedDict, readonly=True):
key2: int
b: B = { "key1": 1, "key2": 2 }
b["key1"] = 4 # Accepted by type checker: "key1" is not read-only
(dataclasses의) frozen에 익숙한 사람이라면 B의 정의만 보고 타입 전체가 읽기 전용이라고 가정하는 것이 합리적일 것입니다. 반면 total에 익숙한 사람이라면 읽기 전용이 현재 타입에만 적용된다고 가정하는 것이 합리적일 것입니다.
원래 제안에서는 B를 이런 방식으로 정의하면 타입 검사 오류와 런타임 오류가 모두 발생하도록 하여 이러한 모호성을 없애려고 했습니다. 그러나 이 방식은 여전히 total처럼 동작하기를 기대하는 사람들을 놀라게 했습니다.
readonly 플래그로 추가로 표현할 수 있는 타입이 없으므로, 모호함과 예상치 못한 동작을 피하기 위해 이 플래그를 제안에서 제거했습니다.
복사 및 기타 메서드를 통한 읽기 전용 한정자 제거를 타입 검사에서 지원하기
이 PEP의 이전 버전에서는 다음과 같은 코드를 타입 검사기가 지원하도록 요구했습니다:
class A(TypedDict):
x: ReadOnly[int]
class B(TypedDict):
x: ReadOnly[str]
class C(TypedDict):
x: int | str
def copy_and_modify(a: A) -> C:
c: C = copy.copy(a)
if not c['x']:
c['x'] = "N/A"
return c
def merge_and_modify(a: A, b: B) -> C:
c: C = a | b
if not c['x']:
c['x'] = "N/A"
return c
그러나 현재 typeshed에서는 이를 표현할 방법이 없으므로, 타입 검사기는 이러한 함수를 특수하게 처리할 수밖에 없습니다. mypy와 pyright가 지원하는 방식으로 이러한 연산을 작성하는 방법이 이미 있지만, 이 방법은 가독성이 다소 떨어진다고 볼 수도 있습니다:
copied: C = { **a }
merged: C = { **a, **b }
이상적인 만큼 유연하지는 않지만, 현재 typeshed 스텁은 건전(sound)하며, 이 PEP가 채택되더라도 계속 건전합니다. typeshed를 갱신하려면 새로운 타이핑 기능이 필요합니다. 예를 들어 두 개 이상의 딕셔너리를 병합한 결과 타입을 표현하는 타입 생성자와, 반환된 값이 공유되지 않음을 나타내는 타입 한정자(따라서 읽기 전용이나 제네릭의 불변성 같은 타입 제약을 특정 방식으로 완화할 수 있음)가 필요하며, 타입 검사기가 이러한 기능을 어떻게 해석해야 하는지에 대한 세부 사항도 필요합니다. 이러한 기능은 언어에 가치 있는 추가 기능이 될 수 있지만, 이 PEP의 범위를 벗어납니다.
이러한 이유로 typeshed 스텁의 갱신은 모두 보류하였습니다.
TypedDict에서 명시되지 않은 키 방지하기
다음의 “타입 판별(type discrimination)” 코드를 살펴보십시오:
class A(TypedDict):
foo: int
class B(TypedDict):
bar: int
def get_field(d: A | B) -> int:
if "foo" in d:
return d["foo"] # !!!
else:
return d["bar"]
이는 흔히 쓰이는 관용구이며, Typescript와 같은 다른 언어에서도 이를 허용합니다. 그러나 엄밀히 말하면 이 코드는 건전하지 않습니다. B는 foo를 선언하지 않지만, B의 인스턴스에는 여전히 해당 키가 존재할 수 있으며, 그에 연관된 값은 어떤 타입이든 될 수 있습니다:
class C(TypedDict):
foo: str
bar: int
c: C = { "foo": "hi", "bar" 3 }
b: B = c # OK: C is structurally compatible with B
v = get_field(b) # Returns a string at runtime, not an int!
mypy는 표시된 줄의 get_field 정의를 TypedDict "B" has no key "foo"라는 오류와 함께 거부합니다. 이는 다소 혼란스러운 오류 메시지이지만, 바로 이 비건전성 때문에 발생합니다.
이를 바로잡는 한 가지 방법은 B가 foo를 갖지 못하도록 명시적으로 막는 것입니다:
class B(TypedDict):
foo: NotRequired[Never]
bar: int
b: B = c # Type check error: key "foo" not allowed in B
그러나 이 방법은 판별에 사용될 수 있는 모든 키를 모든 타입에 명시적으로 선언해야 하므로, 일반적으로 실현 가능하지 않습니다. 더 나은 방법은 명시되지 않은 모든 키가 B에 포함되지 않도록 막는 수단을 마련하는 것입니다. mypy는 PEP 591의 @final 데코레이터를 사용하여 이를 지원합니다:
@final
class B(TypedDict):
bar: int
여기서의 논리는, 이렇게 하면 C나 다른 어떤 타입도 B의 “서브클래스”로 간주되지 않으므로, foo 키가 바닥 타입(bottom type)으로 명시적으로 선언되지 않았더라도 이제 B의 인스턴스가 foo 키를 절대 갖지 않는다고 신뢰할 수 있다는 것입니다.
그러나 읽기 전용 항목이 도입되면, 이 논리에 따라 타입 검사기가 다음 코드를 금지해야 한다는 결론이 나옵니다:
@final
class D(TypedDict):
field: ReadOnly[Collection[str]]
@final
class E(TypedDict):
field: list[str]
e: E = { "field": ["value1", "value2"] }
d: D = e # Error?
여기서의 개념적 문제는 TypedDict가 구조적 타입이라는 점입니다. 즉, TypedDict는 실제로 서브클래싱될 수 없습니다. 따라서 TypedDict에 @final을 사용하는 것은 잘 정의되어 있지 않으며, PEP 591에서도 전혀 언급되지 않습니다.
이 PEP의 이전 버전에서는 다른 종류의 구조적 호환성은 막지 않으면서 다른 키의 사용만 명시적으로 막는 새 플래그를 TypedDict에 추가하여 이 문제를 해결하자고 제안했습니다:
class B(TypedDict, other_keys=Never):
bar: int
b: B = c # Type check error: key "foo" not allowed in B
그러나 초안을 작성하는 과정에서 상황이 바뀌었습니다.
- 이전에는 이 타입 판별 사례에서 mypy와 비슷하게 동작하던 pyright가, 건전하지 않음에도 불구하고 흔히 쓰이는 관용구라는 이유로 원래 예제를 오류 없이 허용하도록 변경되었습니다
- mypy에는 pyright와 Typescript를 따라 이 관용구도 허용하자는 열린 이슈가 있습니다
other_keys기능의 상위 집합인 PEP-728 초안이 작성되었습니다
따라서 이 PEP에서 이 문제를 다뤄야 할 시급성이 줄어들었으며, 이 문제는 PEP-728로 미루어졌습니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.