Python의 타입 시스템에 강력한 타입 수준의 내성(introspection) 및 구성(construction) 기능을 추가하여, TypeScript의 조건부/매핑 타입에서 영감을 받은 타입 조작 연산자와 문법 확장을 제안한다.
Author:Michael J. Sullivan <sully at vercel.com>, Daniel W. Park <daniel.park at vercel.com>, Yury Selivanov <yury at vercel.com>Discussions-To:Discourse threadStatus:Draft Type:Standards Track Topic:TypingCreated:27-Feb-2026 Python-Version:3.15 Post-History:02-Mar-2026
Table of Contents
우리는 Python의 타입 시스템에 강력한 타입 수준의 내성(introspection) 및 구성(construction) 기능을 추가할 것을 제안한다. 이 설계는 주로 TypeScript의 조건부 타입과 매핑 타입에서 영감을 받았지만, Python의 타이핑 모델이 갖는 고유한 의미론과 제약에 맞게 조정되었다.
전체적인 관점에서 이 제안은 다음을 목표로 한다:
typing 모듈에 새로운 프리미티브를 도입한다;Python은 점진적 타입 시스템을 갖고 있지만, 그 핵심에는 상당히 전통적인 정적 타입 시스템이 있다.
반면 언어로서의 Python에서는, 특히 라이브러리와 프레임워크에서 복잡한 메타프로그래밍을 수행하는 일이 드물지 않다. 타입 시스템은 일반적으로 메타프로그래밍을 모델링할 수 없다.
메타프로그래밍과 타입 시스템 사이의 간극을 메우기 위해, 일부 라이브러리는 맞춤형 mypy 플러그인을 포함한다. dataclass 유사 변환의 경우가 충분히 흔하다고 여겨져, 그 사례를 specifically 커버하기 위한 특수 케이스 @dataclass_transform 데코레이터가 추가되었다(PEP 681). 이 접근의 문제는 많은 타입 체커가 플러그인 API를 제공하지 않으며(앞으로도 제공하지 않을 것) IDE, CI, 도구 전반에서 일관된 타입 체킹을 달성하기가 불가능하다는 점이다.
Python 언어의 표현력과 타입 시스템 사이에 큰 불일치가 있는 점을 고려할 때, 우리는 동적 Python 코드의 속도를 더 잘 따라갈 수 있는 타입 조작 기능을 추가하여 이 간극을 메우고자 한다.
이에 대한 수요가 있다. Meta의 2025 Typed Python Survey 응답 분석에서 “가장 많이 요청된 기능” 목록의 첫 항목은 다음과 같았다:
TypeScript 및 다른 언어에서 빠진 기능: 많은 응답자가 TypeScript에서 영감을 받은 기능을 요청했는데, 예를 들면 교차 타입( & 연산자와 유사), 매핑 및 조건부 타입, 유틸리티 타입(Pick, Omit, keyof, typeof 등), 그리고 딕셔너리/dict를 위한 더 나은 구조적 타이핑(예: 더 유연한 TypedDict 또는 익명 타입)이다.
우리는 더 강력한 타입 조작으로 해결할 수 있는 문제의 몇 가지 예를 제시하겠지만, 이 제안은 일반적이며 훨씬 더 많은 사용 사례를 가능하게 할 것이다.
TypeScript에서 인기 있는 ORM인 Prisma는 다음과 같이 TypeScript로 데이터베이스 쿼리를 작성할 수 있게 한다(이 예시에서 각색):
const user = await prisma.user.findMany({ select: { name: true, email: true, posts: true, }, });
이에 대해 user의 추론 타입은 대략 다음과 같을 것이다:
{ email: string; name: string | null; posts: { id: number; title: string; content: string | null; authorId: number | null; }[]; }[]
여기서 출력 타입은 prisma.user의 타입에 대한 기존 정보(데이터베이스 user 테이블에서 반영된 TypeScript 타입)와 findMany() 메서드 인수 타입의 교차(intersection)이다. 이 메서드는 명시적으로 요청된 user의 속성을 포함하는 객체의 배열을 반환하며, posts는 다른 타입을 참조하는 “관계(relation)”이다.
Python에서도 비슷한 일을 할 수 있기를 바란다. 데이터베이스 스키마가 Python에서 정의되어 있거나(또는 데이터베이스에서 코드 생성되었다고) 가정해 보자:
class Comment: id: Property[int] name: Property[str] poster: Link[User]
class Post: id: Property[int]
title: Property[str]
content: Property[str]
comments: MultiLink[Comment]
author: Link[User]
class User: id: Property[int]
name: Property[str]
email: Property[str]
posts: Link[Post]
그렇다면 Python 코드에서 다음과 같은 호출은:
db.select( User, name=True, email=True, posts=True, )
동적으로 계산된 반환 타입 list[<User>]를 가지며, 여기서:
class <User>: name: str email: str posts: list[<Post>]
class <Post>: id: int title: str content: str
더 나아가 IDE는 db.select() 호출의 모든 인수(실제 데이터베이스 컬럼 이름과 일치)를 재귀적으로 코드 완성으로 제안할 수 있다.
(이를 구현하는 예시 코드는 아래에 있다.)
FastAPI 튜토리얼은 간단한 Hero 타입에 대해 CRUD 엔드포인트를 구축하는 방법을 보여준다. 그 핵심은 데이터베이스 인터페이스를 정의하고 엔드포인트에서 데이터의 검증 및 필터링을 수행하는 데 모두 사용되는 일련의 클래스 정의이다:
class HeroBase(SQLModel): name: str = Field(index=True) age: int | None = Field(default=None, index=True)
class Hero(HeroBase, table=True): id: int | None = Field(default=None, primary_key=True) secret_name: str
class HeroPublic(HeroBase): id: int
class HeroCreate(HeroBase): secret_name: str
class HeroUpdate(HeroBase): name: str | None = None age: int | None = None secret_name: str | None = None
HeroPublic 타입은 읽기(read) 엔드포인트의 반환 타입으로 사용되며(출력 시에도 검증되며, 추가 필드는 제거된다), HeroCreate와 HeroUpdate는 입력 타입으로 사용된다(JSON에서 자동 변환되고, Pydantic을 통해 타입에 기반해 검증된다).
여기에는 여러 타입과 중복이 존재하지만, 이 타입들을 도출하기 위한 기계적 규칙을 작성할 수 있다:
FastAPI 프레임워크 내부에 적절한 헬퍼가 정의되어 있다면, 이 제안은 사용자가 다음을 작성할 수 있게 한다:
class Hero(NewSQLModel, table=True): id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
age: int | None = Field(default=None, index=True)
secret_name: str = Field(hidden=True)
type HeroPublic = Public[Hero]
type HeroCreate = Create[Hero]
type HeroUpdate = Update[Hero]
이 타입들을 평가하면 대략 다음과 같이 보일 것이다:
class HeroPublic: id: int name: str age: int | None
class HeroCreate: name: str age: int | None = None secret_name: str
class HeroUpdate: name: str | None = None age: int | None = None secret_name: str | None = None
Public[], Create[], Update[]와 같은 계산 타입의 구현은 비교적 복잡하지만, 수행하는 작업은 매우 기계적이며 프레임워크 라이브러리에 포함된다면 FastAPI 사용자가 유지해야 하는 보일러플레이트를 크게 줄일 것이다.
이 사용 사례의 주목할 만한 특징은 타입 애너테이션을 런타임에 평가해야 한다는 점이다. FastAPI는 Pydantic 모델을 사용해 엔드포인트의 입력과 출력 모두에서 JSON 변환 및 검증을 수행한다.
현재도 런타임 측면만은 가능하다: 원하는 규칙에 기반해 런타임에서 Pydantic 모델을 생성하는 함수를 작성할 수 있다. 그러나 이렇게 하면 함수들을 제대로 정적으로 타입 체크할 수 없다는 점에서 만족스럽지 않다.
(이를 구현하는 예시 코드는 아래에 있다.)
추가로, 객체의 속성에 기반해 메서드 시그니처를 생성할 수 있기를 원한다. 가장 널리 알려진 예는 dataclass의 __init__ 메서드 생성이며, 여기서는 단순화된 예로 제시한다.
이런 패턴은 널리 퍼져 있어, 기존 라이브러리들이 하는 일의 최소 공통분모 부분집합을 나타내기 위해 PEP 681이 만들어졌다.
라이브러리가 이러한 패턴을 타입 시스템에서 더 직접 구현할 수 있게 되면, 추가적인 특수 처리, 타입 체커 플러그인, 하드코딩된 지원 없이도 더 나은 타이핑을 얻을 수 있다.
(이를 구현하는 예시 코드는 아래에 있다.)
데코레이터 함수의 타이핑은 오래전부터 Python 타이핑에서의 고통 지점이었다. PEP 612에서 ParamSpec이 도입되며 상황이 크게 개선되었지만, 여전히 지원되지 않는 패턴이 몇 가지 남아 있다:
TypeVarTuple은 여러 언팩을 허용하고, Pyre가 여러 개를 수정할 수 있는 Map 연산자를 구현한다면 추가/제거를 지원하는 데 가까워진다.)이 제안은 해당 경우들을 다룬다.
주요 제안의 효과를 얻기 위해 필요한 두 가지 하위 제안을 포함한다.
**kwargs를 위한 타입 변수의 UnpackPEP 없이도 타이핑 제안으로 분리될 수 있는 작은 제안:
**kwargs에 대해 타입 변수의 Unpack을 지원한다:
def f[K: BaseTypedDict](**kwargs: Unpack[K]) -> K: return kwargs
여기서 BaseTypedDict는 다음과 같이 정의된다:
class BaseTypedDict(typing.TypedDict): pass
하지만 어떤 TypedDict도 그 자리에 허용된다.
그 다음, 다음과 같은 호출이 있다면:
x: int
y: list[str]
f(x=x, y=y)
K에 대해 추론되는 타입은 대략 다음과 같을 것이다:
TypedDict({'x': int, 'y': list[str]})
이는 기본적으로 PEP 692의 “TypedDict를 사용한 더 정밀한 **kwargs 타이핑”과, PEP 646의 “Variadic Generics”에서 *args에 대한 Unpack 동작을 결합한 것이다.
여기서 타입을 추론할 때 타입 체커는 가능하면 리터럴 타입을 추론해야 한다. 이는 바운드에 등장하지 않는 인수에 대해서도, 바운드에 등장하는 인수 중 read-only인 것에 대해서도 리터럴 타입을 추론한다는 의미다.
바운드에서 required가 아닌 각 항목에 대해, 매칭되는 인수가 제공되지 않았다면, 그 항목이 read-only인 경우 타입은 Never로 추론되어 제공되지 않았음을 나타낸다. (이는 read-only 항목에 대해서만 가능하다. non read-only 항목은 불변(invariant)이기 때문이다.)
이는 그 자체로도 어느 정도 유용하지만, **kwargs를 타입 수준 계산으로 처리하는 것을 지원하기 위해 포함되었다.
임의로 복잡한 callable 타입을 표현하기 위한 새로운 확장 callable 제안을 도입한다. 여기서의 목표는 애너테이션에 직접 쓰기 위한 새로운 문법(그 용도로는 상당히 장황하다)을 추가하는 것이 아니라, 이 PEP의 다른 기능을 사용해 callable 타입을 생성하고 내성할 수 있는 방식에 적합한 타입 구성 수단을 제공하는 것이다.
함수 매개변수에 대한 모든 정보를 담는 Param 타입을 도입한다:
class Param[N: str | None, T, Q: ParamQuals = typing.Never]: pass
ParamQuals = typing.Literal["*", "**", "default", "keyword"]
type PosParam[N: str | None, T] = Param[N, T, Literal["positional"]]
type PosDefaultParam[N: str | None, T] = Param[N, T, Literal["positional", "default"]]
type DefaultParam[N: str, T] = Param[N, T, Literal["default"]]
type NamedParam[N: str, T] = Param[N, T, Literal["keyword"]]
type NamedDefaultParam[N: str, T] = Param[N, T, Literal["keyword", "default"]]
type ArgsParam[T] = Param[Literal[None], T, Literal["*"]]
type KwargsParam[T] = Param[Literal[None], T, Literal["**"]]
그 다음, 다음과 같은 함수의 타입을:
def func( a: int, /, b: int, c: int = 0, *args: int, d: int, e: int = 0, **kwargs: int ) -> int: ...
다음처럼 표현할 수 있다:
Callable[ [ Param[Literal["a"], int, Literal["positional"]], Param[Literal["b"], int], Param[Literal["c"], int, Literal["default"]], Param[None, int, Literal["*"]], Param[Literal["d"], int, Literal["keyword"]], Param[Literal["e"], int, Literal["default", "keyword"]], Param[None, int, Literal["**"]], ], int, ]
또는 제공하는 타입 약어를 사용해:
Callable[ [ PosParam[Literal["a"], int], Param[Literal["b"], int], DefaultParam[Literal["c"], int], ArgsParam[int], NamedParam[Literal["d"], int], NamedDefaultParam[Literal["e"], int], KwargsParam[int], ], int, ]
(근거는 아래에서 논의한다.)
위 예시에서 보였듯이, 유효한 타입의 새로운 문법 형태를 몇 가지 도입하지만, 대부분의 강력함은 typing 모듈에 정의될 타입 수준 연산자에서 나온다.
Python 문법 변경은 제안하지 않으며, Python 표현식 중 어떤 것이 유효한 타입으로 간주되는지에 대한 문법만 확장한다.
<type> = ... # 타입 불리언도 모두 유효한 타입이다 | <type-bool>
# 조건부 타입
| <type> if <type-bool> else <type>
# 가변 인자를 갖는 타입은
# *[... for t in ...] 인자를 가질 수 있다
| <ident>[<variadic-type-arg> +]
# 타입 멤버 접근
| <type>.<name>
| GenericCallable[<type>, lambda <args>: <type>]
<type-bool> = <bool-operator>[<type> +] | not <type-bool> | <type-bool> and <type-bool> | <type-bool> or <type-bool> | any(<type-bool-for>) | all(<type-bool-for>)
<variadic-type-arg> = <type> , | * [ <type-for-iter> ] ,
<type-for> = <type> <type-for-iter>+ <type-for-if>* <type-for-iter> = # 튜플 타입을 순회 for <var> in Iter[<type>] <type-for-if> = if <type-bool>
여기서:
<bool-operator>는 불리언 연산자 섹션에 정의된 어떤 이름이든(직접 사용, 한정(qualified) 사용, 다른 이름으로 사용 포함) 가리킨다.<type-bool-for>는 결과 타입이 <type> 대신 <type-bool>이라는 점을 제외하면 <type-for>와 동일하다.도입되는 핵심 문법 기능은 세 가지 반(半)이다: 타입 불리언, 조건부 타입, 언팩된 컴프리헨션 타입, 타입 멤버 접근.
“제네릭 callable”도 기술적으로는 문법 기능이지만, 연산자로서 논의한다.
타입 불리언은 조건부 본문에서 사용할 수 있는 타입 언어의 특별한 부분집합이다. 이는 아래에서 정의되는 불리언 연산자로 구성되며, 필요에 따라 and, or, not, all, any로 결합될 수 있다. all과 any의 인수는 타입 불리언 컴프리헨션이며, 언팩된 컴프리헨션과 동일한 방식으로 평가된다.
타입 애너테이션 컨텍스트에서 평가될 때, Literal[True] 또는 Literal[False]로 평가된다.
조건부에서 사용할 수 있는 연산자를 제한하는 이유는, 런타임에서 해당 연산자들이 적절한 동작을 하는 “타입” 값을 생성하도록 하되, 기존의 Literal[False] 등의 동작을 변경하지 않기 위해서다.
true_typ if bool_typ else false_typ 타입은 조건부 타입이며, bool_typ이 Literal[True]와 동등하다면 true_typ로, 그렇지 않으면 false_typ로 해석된다.
bool_typ은 타입이지만, 위에서 정의된 타입 불리언으로 문법적으로 제한되어야 한다.
언팩된 컴프리헨션 *[ty for t in Iter[iter_ty]]는 현재 Unpack[...]이 허용되는 어떤 타입 위치에도 등장할 수 있으며, 본질적으로는 튜플 타입 iter_ty의 인자들을 순회하는 리스트 컴프리헨션으로 생성된 튜플에 대한 Unpack으로 평가된다.
컴프리헨션은 또한 if 절을 가질 수 있으며, 이는 일반적인 방식으로 필터링한다.
클래스 멤버와 함수 매개변수를 표현하기 위해 도입된 Member와 Param 타입은, 점 표기법으로 접근할 수 있는 “연관(associated)” 타입 멤버를 가진다: m.name, m.type 등.
이 연산은 유니온 타입에 대해 리프트되지 않는다. 잘못된 종류의 타입에 사용하면 오류가 된다. 런타임에서 반드시 그래야 하며, 타입 체킹도 이에 맞추고자 한다.
명세된 많은 연산자는 일부 피연산자에 대해 타입 바운드를 나열한다. 이는 정확한 바운드라기보다 문서로 해석해야 한다. 잘못된 인수로 연산자를 평가하려 하면 반환으로 Never가 생성된다. (가능한 대안에 대한 논의는 아래에 있다.)
아래의 바운드 중 일부에서는 Literal[int]처럼 “int 타입인 리터럴”을 의미하는 표기를 쓰지만, 이를 실제 문법으로 추가하자는 제안은 아직 아니다.
IsAssignable[T, S]: T가 S에 할당 가능한지 여부를 나타내는 불리언 리터럴 타입을 반환한다.
즉, 이는 “일관된 서브타입(consistent subtype)”이다. 이는 점진적 타입까지 확장된 서브타이핑이다.
IsEquivalent[T, S]: IsAssignable[T, S] and IsAssignable[S, T]와 동등하다. 기술적으로 이 관계는 타이핑 스펙에서 동등(equivalence)이 아니라 “일관성(consistency)”이다.
Bool[T]: T가 Literal[True]이거나 이를 포함하는 유니온이면 Literal[True]를 반환한다. IsAssignable[T, Literal[True]] and not IsAssignable[T, Never]와 동등하다.
이는 불리언 리터럴 타입을 반환하는 “헬퍼 별칭”을 호출하는 데 유용하다.
GetArg[T, Base, Idx: Literal[int]]: T를 Base로 해석했을 때의 Idx번째 타입 인자를 반환하거나, 그렇게 해석할 수 없으면 Never를 반환한다. (즉, class A(B[C]): ...라면 GetArg[A, B, Literal[0]] == C이며 GetArg[A, A, Literal[0]] == Never이다.)
음수 인덱스는 일반적인 방식으로 동작한다.런타임 평가는 Base로 적절한 클래스만 지원할 수 있으며, 프로토콜은 지원하지 못한다. 예를 들어,
GetArg[Ty,
Iterable, Literal[0]]
처럼 어떤 iterable의 타입을 얻는 것은 런타임 평가기에서는 실패한다.
특수 형식(special forms)은 특별한 처리가 필요하다: Callable의 인자 목록은 튜플로 패킹되며, ...는 SpecialFormEllipsis가 된다.
GetArgs[T, Base]: T를 Base로 해석했을 때의 모든 타입 인자를 담은 튜플을 반환하거나, 해석할 수 없으면 Never를 반환한다.GetMemberType[T, S: Literal[str]]: 클래스 T에서 이름이 S인 멤버의 타입을 추출한다.Length[T: tuple] - 튜플의 길이를 int 리터럴로 가져온다(경계가 없는 경우 Literal[None]).Slice[S: tuple, Start: Literal[int | None], End: Literal[int | None]]: 튜플 타입을 슬라이스한다.GetSpecialAttr[T, Attr: Literal[str]]: 클래스 T에서 이름이 Attr인 특수 속성의 값을 추출한다. 유효한 속성은 __name__, __module__, __qualname__이다. 값은 Literal[str]로 반환된다.이 섹션의 모든 연산자는 유니온 타입에 대해 리프트된다.
FromUnion[T]: 유니온의 모든 요소를 담은 튜플을 반환하거나, 유니온이 아니라면 T를 담은 길이 1의 튜플을 반환한다.Union[*Ts]: Union은 가변 인자를 받을 수 있게 되어, 언팩된 컴프리헨션 인자를 받을 수 있다.Members[T]: 클래스 또는 typed dict T의 멤버(속성과 메서드)를 설명하는 Member 타입의 tuple을 생성한다.
타입 체크 시간과 런타임 평가가 더 가깝게 일치하도록, 명시적 타입 애너테이션이 있는 멤버만 포함한다.
Attrs[T]: Members[T]와 같지만 속성만 반환한다(메서드는 제외).
GetMember[T, S: Literal[str]]: 클래스 T에서 이름이 S인 멤버에 대한 Member 타입을 생성한다.
Member[N: Literal[str], T, Q: MemberQuals, Init, D]: Member는 연산자가 아니라 단순 타입이며, 클래스의 멤버를 기술하는 데 사용된다. 타입 파라미터는 각 멤버에 대한 정보를 인코딩한다.
N은 리터럴 문자열 타입인 이름이다. .name으로 접근 가능.T는 타입이다. .type으로 접근 가능.Q는 한정자(아래 MemberQuals 참조)의 유니온이다. .quals로 접근 가능.Init는 클래스에서의 속성 초기화 식의 리터럴 타입이다(InitField 참조). .init으로 접근 가능.D는 멤버를 정의한 클래스이다(즉, 멤버가 어떤 클래스에서 상속되었는지). TypedDict에서는 항상 Never이다. .definer로 접근 가능.MemberQuals = Literal['ClassVar', 'Final', 'NotRequired', 'ReadOnly'] - MemberQuals는 멤버에 적용될 수 있는 “한정자”의 타입이다; 현재 ClassVar와 Final은 클래스에, NotRequired와 ReadOnly는 typed dict에 적용된다.
메서드는 새로운 Param 기반 확장 callable을 사용한 callable로 반환되며, ClassVar 한정자를 가진다. staticmethod 및 classmethod는 staticmethod 및 classmethod 타입을 반환하며, 이는 Python 3.14부터 서브스크립트가 가능하다.
이 섹션의 모든 연산자는 유니온 타입에 대해 리프트된다.
NewProtocol[*Ms: Member]: Member 인수로 지정된 멤버를 갖는 새로운 구조적 프로토콜을 생성한다NewTypedDict[*Ps: Member] - Member 인수로 지정된 항목을 갖는 새로운 TypedDict를 생성한다.현재는 명목적(nominal) 클래스 생성 방법이나 새로운 제네릭 타입을 만드는 방법은 제안하지 않는다.
dataclasses/attrs/Pydantic 스타일 필드 디스크립터에 기반해 타입을 변환할 수 있도록 지원하고자 한다. 이를 위해서는 Field 호출과 같은 연산을 소비(consuming)할 수 있어야 한다.
이를 위한 전략은 KwargDict: TypedDict로 정의된 인수를 수집하는 새로운 타입 InitField[KwargDict]를 도입하는 것이다:
class InitField[KwargDict: BaseTypedDict]: def init (self, **kwargs: typing.Unpack[KwargDict]) -> None: ...
def _get_kwargs(self) -> KwargDict:
...
InitField(혹은 더 흔하게는 그 서브타입)가 클래스 본문 안에서 인스턴스화되면, 가능하면 Literal 타입에 기반해 더 구체적인 타입을 추론한다. (실제로 이는 **kwargs에서 타입 변수 언팩이 Literal 타입을 사용해야 한다는 규칙의 적용에 불과하다.)
따라서 다음과 같이 작성하면:
class A: foo: int = InitField(default=0, kw_only=True)
초기화자에 대해 다음 타입을 추론한다:
InitField[TypedDict('...', {'default':
Literal[0], 'kw_only': Literal[True]})]
그리고 이 값은 Member의 Init 필드로 제공된다.
Callable 타입은 항상 위에서 논의한 확장 Callable 형식으로 인자를 노출한다.
이름, 타입, 한정자는 Member와 동일한 연관 타입 이름(.name, .type, .quals)을 공유한다.
GenericCallable[Vs, lambda <vs>: Ty]: 제네릭 callable. Vs는 바인딩되지 않은 타입 변수들의 튜플 타입이며, Ty는 Callable, staticmethod, classmethod 중 하나여야 하며, <vs>의 바인딩된 변수들을 통해 Vs의 변수들에 접근할 수 있다.현재로서는 GenericCallable의 사용을 Member의 타입 인수로 제한하여, 로컬, 매개변수 타입, 반환 타입, 다른 타입 내부 중첩 등에서의 사용을 금지한다. 근거는 아래에서 논의한다.
Overloaded[*Callables] - 기저 타입을 순서대로 갖는 오버로드 함수 타입.RaiseError[S: Literal[str], *Ts]: 어떤 실제 타입을 결정하기 위해 이 타입을 평가해야 한다면, 제공된 메시지로 타입 오류를 생성한다.
추가 타입 인수는 메시지에 포함되어야 한다.UpdateClass[*Ps: Member]: 새로운 멤버로 기존 명목적 클래스를 _갱신_하는 특수 형식이다(기존 멤버를 오버라이드하거나, 타입을 Never로 만들어 제거할 수도 있다).
이는 타입 데코레이터의 반환 타입 또는 __init_subclass__의 반환 타입으로만 사용할 수 있다.클래스가 선언될 때, 하나 이상의 조상이 UpdateClass 반환 타입을 갖는 __init_subclass__를 가지고 있다면, 역 MRO 순서로 적용된다. 주의: cls 매개변수가 type[T]로 매개변수화되어 있다면, 클래스 타입이 T에 대입되어야 한다.
많은 내장 연산은 Union에 대해 “리프트(lifted)”된다.
예를 들어:
Concat[Literal['a'] | Literal['b'], Literal['c'] | Literal['d']] = ( Literal['ac'] | Literal['ad'] | Literal['bc'] | Literal['bd'] )
연산이 유니온 타입에 대해 리프트되면, 각 인수 위치의 유니온 요소에 대해 데카르트 곱을 취하고, 곱의 각 튜플에 대해 연산자를 평가한 다음, 그 결과를 모두 유니온으로 합친다. Python에서는 로직이 다음과 같이 보인다:
args_union_els = [get_union_elems(arg) for arg in args] results = [ eval_operator(*xs) for xs in itertools.product(*args_union_els) ] if results: return Union[*results] else: return Never
중요한 목표 중 하나는 이러한 계산 타입의 런타임 평가를 지원하는 것이다. 표준 라이브러리에 공식 평가기를 추가하는 것은 제안하지 않지만, 서드파티 평가기 라이브러리를 릴리스할 계획이다.
타입 시스템 확장의 대부분은 “비활성(inert)” 타입 연산자 적용이지만, 문법에는 리스트 반복, 조건, 속성 접근도 포함되며, 이는 클래스/별칭/함수의 __annotate__ 메서드가 호출될 때 자동으로 평가된다.
평가기 라이브러리가 이러한 경우 타입 평가를 트리거할 수 있도록, typing에 새로운 훅을 추가한다:
special_form_evaluator: 이는 ContextVar이며, 불리언 연산자에서 __bool__이 호출되거나 typing.Iter에서 __iter__가 호출될 때 typing._GenericAlias 인수로 호출될 callable을 보관한다. 반환된 값은 반환되기 전에 bool 또는 iter가 호출된다.
None으로 설정되면(기본값), 불리언 연산자는 False를 반환하고 Iter는 iter(())로 평가된다.애너테이션을 가져오는 Format.AST 모드 추가에 대한 논의가 있었다(이 PEP 초안 참조). 이는 이 제안과 매우 잘 맞는데, 완전히 평가되지 않은 애너테이션을 쉽게 가져올 수 있게 해주기 때문이다.
여기서는 동기 섹션의 예시 목표를 달성하는 방법을 튜토리얼 방식으로 설명하며, 사용하는 기능은 사용하면서 설명한다.
먼저, 위에서 본 애너테이션을 지원하기 위해 제네릭 타입을 가진 더미 클래스들을 준비한다.
class Pointer[T]: pass
class PropertyT: pass
class LinkT: pass
class SingleLinkT: pass
class MultiLinkT: pass
select 메서드에서 새로운 요소들이 등장하기 시작한다.
**kwargs: Unpack[K]는 이 제안의 일부이며, 키워드 인수로부터 TypedDict를 _추론_할 수 있게 한다.
Attrs[K]는 K의 모든 타입 애너테이션된 속성에 대응하는 Member 타입을 추출하고, Member 인수로 NewProtocol을 호출하면 새로운 구조적 타입을 구성한다.
c.name은 변수 c에 바인딩된 Member의 이름을 리터럴 타입으로 가져온다—이 메커니즘들은 리터럴 타입에 매우 크게 의존한다. GetMemberType는 클래스에서 속성의 타입을 가져온다.
def select[ModelT, K: typing.BaseTypedDict]( typ: type[ModelT], /, **kwargs: Unpack[K], ) -> list[ typing.NewProtocol[ *[ typing.Member[ c.name, ConvertField[typing.GetMemberType[ModelT, c.name]], ] for c in typing.Iter[typing.Attrs[K]] ] ] ]: raise NotImplementedError
ConvertField는 첫 번째 타입 헬퍼이며, (제한된) 서브타입 유사 검사에 기반해 두 타입 중 하나를 선택하는 조건부 타입 별칭이다.
ConvertField에서는 Property 또는 Link 애너테이션을 제거하고, 기저 타입을 생성하고자 하며, 링크의 경우에는 속성만 포함하는 새 대상 타입을 만들고 MultiLink는 리스트로 감싼다.
type ConvertField[T] = ( AdjustLink[PropsOnly[PointerArg[T]], T] if typing.IsAssignable[T, Link] else PointerArg[T] )
PointerArg는 Pointer 또는 서브클래스에 대한 타입 인자를 얻는다.
GetArg[T, Base, I]는 핵심 프리미티브 중 하나로, 타입 T가 Base를 상속할 때 Base의 I번째 타입 인자를 가져온다.
(이의 미묘한 점은 나중에 논의한다; 여기서는 단지 Pointer에 대한 인자를 가져온다.)
type PointerArg[T] = typing.GetArg[T, Pointer, Literal[0]]
AdjustLink는 앞서 논의한 기능을 사용해 MultiLink 주위에 list를 붙인다.
type AdjustLink[Tgt, LinkTy] = ( list[Tgt] if typing.IsAssignable[LinkTy, MultiLink] else Tgt )
마지막 헬퍼 PropsOnly[T]는 T의 모든 Property 속성을 포함하는 새로운 타입을 생성한다.
type PropsOnly[T] = typing.NewProtocol[ *[ typing.Member[p.name, PointerArg[p.type]] for p in typing.Iter[typing.Attrs[T]] if typing.IsAssignable[p.type, Property] ] ]
전체 테스트는 테스트 스위트에 있다.
테스트 스위트에는 더 완성된 예시가 있지만, 여기서는 Create만의 가능한 구현을 보여준다:
type GetDefault[Init] = ( GetFieldItem[Init, Literal["default"]] if typing.IsAssignable[Init, Field] else Init )
type Create[T] = typing.NewProtocol[ *[ typing.Member[ p.name, p.type, p.quals, GetDefault[p.init], ] for p in typing.Iter[typing.Attrs[T]] if not typing.IsAssignable[ Literal[True], GetFieldItem[p.init, Literal["primary_key"]], ] ] ]
Create 타입 별칭은 원래 타입의 속성을 순회하여 새로운 타입(NewProtocol을 통해)을 생성한다. 이름, 타입, 한정자, 그리고 초기화자의 리터럴 타입에 접근할 수 있으며(여기서 사용되는 매우 흔한 = Field(...) 패턴을 다루기 위한 새로운 기능 덕분에 가능해진 부분도 있다).
여기서는 Field에 primary_key=True가 있는 속성을 필터링하고, 기본 인수(필드의 default 인수에서 오거나 초기화자로 직접 지정될 수 있음)를 추출한다.
InitFnType는 모든 속성을 순회하여 새로운 __init__ 함수에 대한 Member를 생성한다.
여기서의 GetDefault는 위의 FastAPI 유사 예시에서 가져왔다.
type InitFnType[T] = typing.Member[ Literal["init"], Callable[ [ typing.Param[Literal["self"], Self], *[ typing.Param[ p.name, p.type, # 모든 인수는 키워드 전용 # 클래스에 기본값이 지정된 경우 기본값을 받는다 Literal["keyword"] if typing.IsAssignable[ GetDefault[p.init], Never, ] else Literal["keyword", "default"], ] for p in typing.Iter[typing.Attrs[T]] ], ], None, ], Literal["ClassVar"], ]
type AddInit[T] = typing.NewProtocol[ InitFnType[T], *[x for x in typing.Iter[typing.Members[T]]], ]
그 다음 UpdateClass를 사용해(예: @dataclass) 클래스에 새로운 __init__ 메서드를 추가하는 클래스 데코레이터를 만들 수 있다.
def dataclass_ish[T]( cls: type[T], ) -> typing.UpdateClass[ # 계산된 init 함수를 추가 InitFnType[T], ]: pass
또는 그렇게 하는 베이스 클래스(예: Pydantic)를 만들 수도 있다.
class Model: def init_subclass [T]( cls: type[T], ) -> typing.UpdateClass[ # 계산된 init 함수를 추가 InitFnType[T], ]: super(). init_subclass ()
PEP 646에서 TypeVarTuple을 도입한 동기 중 하나는 다음과 같은 다차원 배열의 shape를 표현하는 것이다:
x: Array[float, L[480], L[640]] = Array()
해당 PEP의 예시는 TypeVarTuple을 사용하여 산술 연산의 양쪽이 shape가 일치함을 보장하는 방법을 보여준다. 그러나 대부분의 다차원 배열 라이브러리는 브로드캐스팅도 지원하여 서로 다른 shape의 데이터를 섞을 수 있게 한다. 이 PEP를 통해 Broadcast[A, B] 타입 별칭을 정의하고, 이를 반환 타입으로 사용할 수 있다:
class Array[DType, *Shape]: def add [*Shape2]( self, other: Array[DType, *Shape2] ) -> Array[DType, *Broadcast[tuple[*Shape], tuple[*Shape2]]]: raise BaseException
(TypeVarTuple을 또 다른 tuple로 감싸는 다소 어색한 문법은, 현재 타입 체커가 두 개의 TypeVarTuple 인수를 허용하지 않기 때문이다. 가능한 개선으로는, 별표가 없거나 Unpack되지 않은 변수 이름 자체를 튜플로 해석하는 의미로 허용하는 것이 있다.)
그 다음 다음을 할 수 있다:
a1: Array[float, L[4], L[1]]
a2: Array[float, L[3]]
a1 + a2 # Array[builtins.float, Literal[4], Literal[3]]
b1: Array[float, int, int]
b2: Array[float, int]
b1 + b2 # Array[builtins.float, int, int]
err1: Array[float, L[4], L[2]]
err2: Array[float, L[3]]
이는 타입 조작의 표현력을 보여주기 위한 예시이며, 텐서 타입의 타이핑에 대한 최종 제안은 아니다.
class Array[DType, *Shape]: def add [*Shape2]( self, other: Array[DType, *Shape2] ) -> Array[DType, *Broadcast[tuple[*Shape], tuple[*Shape2]]]: raise BaseException
MergeOne은 브로드캐스팅 연산의 핵심이다. 두 타입이 동등하면 첫 번째를 취하고, 둘 중 하나가 Literal[1]이면 다른 것을 취한다.
불일치 시에는 RaiseError 연산자를 사용해 두 타입을 식별하는 오류 메시지를 생성한다.
type MergeOne[T, S] = ( T if typing.IsEquivalent[T, S] or typing.IsEquivalent[S, Literal[1]] else S if typing.IsEquivalent[T, Literal[1]] else typing.RaiseError[Literal["Broadcast mismatch"], T, S] )
type DropLast[T] = typing.Slice[T, Literal[0], Literal[-1]]
type Last[T] = typing.GetArg[T, tuple, Literal[-1]]
type Empty[T] = typing.IsAssignable[typing.Length[T], Literal[0]]
Broadcast는 입력 튜플을 재귀적으로 내려가며 MergeOne을 적용하다가, 둘 중 하나가 비면 멈춘다.
type Broadcast[T, S] = ( S if typing.Bool[Empty[T]] else T if typing.Bool[Empty[S]] else tuple[ *Broadcast[DropLast[T], DropLast[S]], MergeOne[Last[T], Last[S]], ] )
타입 수준 계산을 통해 callable을 검사하고 생성하기 위해서는 확장 callable 지원이 필요하다. mypy는 확장 callable을 지원하지만, 콜백 프로토콜을 선호하며 이는 deprecated이다.
불행히도 콜백 프로토콜은 타입 수준 계산에 잘 맞지 않는다. (작동하도록 만들 수는 있겠지만, _메서드_를 생성하고 내성하기 위한 별도의 기능이 필요하며 더 단순해지지 않는다.)
완전히 새로운 확장 callable 문법을 제안하는 이유는:
mypy_extensions 함수들은 완전한 no-op이어서, 실제 런타임 객체가 필요하다.mypy_extensions와 매우 유사한 확장 callable을 도입할 수도 있지만).다음 시그니처를 가진 메서드를 고려하자:
def process[T](self, x: T) -> T if IsAssignable[T, list] else list[T]: ...
이 메서드의 타입은 제네릭이며, 제네릭은 클래스가 아니라 메서드에서 바인딩된다. NewProtocol을 위해 프로그래머가 작성할 수 있는 이런 제네릭 함수를 표현할 방법이 필요하다.
다소 매력적이지만 작동하지 않는 한 가지 옵션은 바인딩되지 않은 타입 변수를 사용하고 이를 일반화하도록 두는 것이다:
type Foo = NewProtocol[ Member[ Literal["process"], Callable[[T], set[T] if IsAssignable[T, int] else T] ] ]
문제는 이것이 런타임 평가 지원과 기본적으로 양립하기 어렵다는 점이다. 별칭 Foo를 평가하려면 IsAssignable을 평가해야 하며, 최소한 조건부의 한쪽을 잃게 된다. 제네릭 함수를 가진 클래스에서 Members를 평가할 때도 비슷한 문제가 생긴다. 본문을 lambda로 감싸면 두 경우 모두에서 평가를 지연시킬 수 있다. (Members에서의 평가 지연은 명시적 제네릭 애너테이션이 있는 함수에 대해 꽤 잘 작동한다. 구식 제네릭의 경우에는 아마 평가를 시도한 뒤 변수를 만나면 오류를 발생시켜야 할 것이다.)
GenericCallable의 사용을 Member의 타입 인수로 제한하자고 제안하는 이유는, 임프레디커티브 다형성(타입 변수를 다른 제네릭 타입으로 인스턴스화 가능)과 rank-N 타입(함수 타입의 깊은 중첩 위치에서 제네릭을 바인딩)이 타입 추론과 결합될 때 매우 어려운 문제가 되기 때문이다[1]. 지원되면 좋겠지만, 지금은 그 문제 상자를 열고 싶지 않다.
바인딩되지 않은 타입 변수 튜플을 사용하는 이유는 바운드, 기본값, TypeVarTuple 여부 등을 지정할 수 있게 하기 위해서지만, 다른 접근을 찾고 싶을 수도 있다.
엄밀히 말하면, 이 PEP는 새로운 기능만 제안하므로 하위 호환성 문제가 없어야 한다.
다만 넓게 보면, 타입 애너테이션에서 if와 for의 사용은 타입 애너테이션을 추출하려는 도구에 문제를 일으킬 수 있다.
애너테이션을 완전히 평가하려는 도구는 평가기를 구현하거나 이를 위한 라이브러리를 사용해야 한다(PEP 저자들은 그런 라이브러리를 만들 계획이다).
런타임에서 애너테이션을 내성하는 데 의존하는 도구(소스 파일을 파싱하는 도구는 영향 없음)가 평가되지 않은 애너테이션을 추출해 어떤 방식으로 처리하고자 한다면 더 곤란할 수 있다. 현재는 from __future__ import annotations가 지정되어 있다면 가능하다. 문자열 애너테이션을 ast.parse로 파싱하여 임의의 방식으로 처리할 수 있기 때문이다.
그렇지 않다면, 현재 상태에서는 더 까다로워진다. 루프와 조건을 포함하는 경우 __annotate__ 함수에서 유용한 정보를 실행 없이 얻을 방법이 없고, 문자열을 구축하기 위한 트릭도 루프와 조건에서는 동작하지 않는다.
이는 다음 중 하나로 완화될 수 있다:
Format.AST 모드 추가(이 PEP 초안 참조)이 옵션들 중 어느 것도 선택되지 않는다면, 평가되지 않은 타입 조작 표현식을 처리하려는 도구는 소스 코드를 다시 파싱하고 그곳에서 애너테이션을 추출해야 할 것이다. 이는 대부분의 도구가 어차피 하는 방식이라고 예상한다.
예상되는 영향은 없다.
TypeScript가 유사 기능을 가르치는 방식에서 많은 영감을 얻을 수 있다고 본다. 복잡도가 비슷하므로, TypeScript처럼 상위 수준의 예시 주도 문서가 필요할 것이다.
또한 새로운 문법과 API의 예상 대상은 프레임워크 및 라이브러리 유지보수자이며, 그들이 도입하는 고급 패턴과 API를 지원하기 위해 타입 조작을 구현하게 될 것이라는 점이 중요하다.
런타임 평가기의 데모가 있으며, 이 PEP 초안이 현재 위치한 곳이기도 하다.
mypy에서 진행 중인 개념 증명 구현이 있다.
이는 ORM, FastAPI 스타일 모델 도출, NumPy 스타일 브로드캐스팅 예시를 타입 체크할 수 있다.
callable, UpdateClass, 애너테이션 처리, 그리고 여러 작은 것들에 대한 지원은 아직 부족하다.
즉, ‘“거절된” 아이디어이지만 어쩌면 실제로 해야 할지도?’
이에 대한 피드백을 매우 원한다!
이는 어떤 면에서는 (여전히 초안인) 인라인 typed dict에 대한 PEP 764 제안의 확장이다.
위 제안과 결합하여 NewProtocol에 이를 사용하면, ( 쿼리 빌더 예시에서 가져온 것을 사용해) 다음과 같이 보일 수 있다:
type PropsOnly[T] = typing.NewProtocol[ { p.name: PointerArg[p.type] for p in typing.Iter[typing.Attrs[T]] if typing.IsAssignable[p.type, Property] } ]
그렇다면 한정자 및/또는 초기화자 타입을 지정하고 싶을 경우를 위해 Member도 지정할 수 있게 하고 싶을 것이다(단 Name을 마지막으로 옮기고 기본값을 갖게 하도록 재정렬).
또한 한정자를 타입에 쓸 수 있게 할 수도 있지만, 이는 애너테이션 표현식이지 타입 표현식이 아니라는 점에서 다소 이상하며, 조건부 타입의 분기(arm) 안에 애너테이션 표현식을 허용하지 않을 가능성이 크다.
이 제안의 주요 단점은 복잡성이다: 또 다른 종류의 이상한 타입 형태를 도입해야 한다.
또한 TypedDict와 새 프로토콜 간의 정확한 상호작용을 정해야 한다. 딕셔너리 문법은 항상 typed dict를 만들고 NewProtocol이 이를 프로토콜로 변환해야 할까, 아니면 NewProtocol[<dict type expr>]가 특수 형식이어야 할까? ClassVar와 Final도 허용할까?
또 다른 잠재적 “단점”(사실은 장점일 수도!)은, Attrs와 Members를 items() 스타일 이터레이터로 순회하고 싶어질 수 있다는 점이며, 이는 더 복잡한 질문을 제기한다.
먼저 문법은 다음과 같을 것이다:
type PropsOnly[T] = typing.NewProtocol[ { k: PointerArg[ty] for k, ty in typing.IterItems[typing.Attrs[T]] if typing.IsAssignable[ty, Property] } ]
이는 꽤 좋아 보이지만, 이름과 타입에만 접근 가능하고 한정자나 초기화자에는 접근할 수 없다.
이를 처리하기 위한 잠재적 옵션:
.items() 스타일 이터레이터를 쓰고, 필요할 때는 완전한 Member 객체로 작업하면 된다.key에 넣을 수 있다. 그러면 이름을 쓰려면 key.name 등으로 해야 한다.(또한 이렇게 순회 가능한 것에 대한 규칙이 정확히 무엇인지도 정해야 한다.)
대괄호 도시(Bracket City)에서 고생하는 사람이 많다면, 내장 타입 연산자가 대괄호 대신 괄호를 사용하도록 하는 것도 고려할 수 있다.
[]와 ()를 섞어 쓰면 일관성 문제가 생기고, 사용자가 어떤 API가 대괄호를 쓰고 어떤 API가 괄호를 쓰는지 기억해야 한다. 현재 Python 타이핑이 대괄호 사용을 중심으로 돌아가는 점을 고려할 때, 그 길을 계속 가는 것이 더 나은 개발자 경험으로 이어진다고 강하게 생각한다.
예를 들어 ()와 []를 섞으면 다음처럼 보일 수 있다:
type PropsOnly[T] = typing.NewProtocol( { p.name: PointerArg[p.type] for p in typing.Iter(typing.Attrs(T)) if typing.IsAssignable(p.type, Property) } )
(사용자 정의 타입 별칭 PointerArg는 본질적으로 헬퍼 연산자임에도 여전히 대괄호로 호출해야 한다.)
현재의 주요 제안은 Member와 Param이 .name, .type에 대한 연관 타입을 정확히 어떻게 갖게 되는지에 대해 침묵하고 있다.
특정 타입에 대해서만 동작하게 만들 수도 있고, 다음과 같은 일반 메커니즘을 도입할 수도 있다:
@typing.has_associated_types class Member[ N: str, T, Q: MemberQuals = typing.Never, I = typing.Never, D = typing.Never ]: type name = N type tp = T type quals = Q type init = I type definer = D
데코레이터(또는 베이스 클래스)는 연관 타입의 점 표기법이 런타임에서도 동작하게 하려면 필요하다. 클래스가 생성하는 typing._GenericAlias에 대해 __getattr__의 동작을 커스터마이즈해 Member의 타입 파라미터와 별칭 모두를 캡처해야 하기 때문이다.
(혹은 _GenericAlias 자체의 동작을 바꾸어 데코레이터가 필요 없게 할 수도 있다.)
이는 문법 형태를 실험하기 위한 유연성을 더 주고, 언팩된 컴프리헨션 타입에서 typing.Iter를 요구한다든지, 조건부 타입에서 제한된 <type-bool> 표현식 집합만을 허용하는 것 같은 못생긴 부분을 제거할 수 있게 해준다.
하지만 좋든 나쁘든 타입 애너테이션의 런타임 사용은 널리 퍼져 있으며(예: pydantic이 이에 의존), 동기 예시 중 하나( FastAPI CRUD 모델 자동 도출)도 이에 의존한다.
TypeScript에서 조건부 타입은 다음처럼 형성된다:
SomeType extends OtherType ? TrueType : FalseType
또한 검사 오른쪽은 패턴 매칭에 기반해 infer 키워드로 타입 변수를 바인딩할 수 있으며, 예를 들어 배열의 요소 타입을 추출하는 다음 예시가 있다:
type ArrayArg<T> = T extends [infer El] ? El : never;
이는 특히 typing.GetArg 및 그 미묘한 Base 파라미터의 필요성을 제거한다는 점에서 매우 우아한 메커니즘이다.
불행히도 이를 Python의 기존 문법에 만족스럽게 끼워 넣기는 매우 어려워 보인다. 특히 미묘한 바인딩 구조 때문이다.
가장 그럴듯한 변형은 다음과 같은 형태일 수 있다:
type ArrayArg[T] = El if IsAssignable[T, list[Infer[El]]] else Never
그런데 이를 런타임에 평가하려면, 바인딩되지 않은 Infer 인수를 잡아내는 커스텀 globals 환경 등 지저분한 작업이 필요하다.
또한 큰 문법 변경(삼항 연산 대신 타입 연산자 사용) 없이는 TypeScript의 조건부가 유니온에 대해 리프트되는 동작을 맞출 수 없다.
IsAssignable 대체Python 타이핑에서의 완전한 할당 가능성 검사는 런타임에서 완전히 구현할 수 없다(특히 stdlib에 대한 typeshed 타입을 모두 사용할 수 있다 하더라도, 프로토콜에 대한 검사는 종종 불가능한데, 클래스 속성은 추론될 수 있고 런타임에 가시적으로 존재하지 않을 수 있기 때문이다).
제안된 형태대로라면 런타임 평가기는 “최선의 노력(best effort)”이 되어야 하며, 그 노력의 범위가 잘 문서화되면 좋을 것이다.
대안 접근은 더 약한 술어를 핵심 프리미티브로 두는 것이다.
한 가지 가능성은 “부분 유사(sub-similarity)” 검사이다: IsAssignableSimilar는 타입 파라미터를 보지 않고, 타입의 _헤드_만 단순 검사한다. 이는 프로토콜에서는 동작하지 않는다. 또한 유니온에 대해 리프트되며 리터럴도 검사한다.
서브타이핑과 비슷하지만 동일하지 않은 새로운 개념을 도입하는 것은 좋은 생각이 아닐 수 있다고 판단했고, 그 경우 IsAssignableSimilar 같은 길고 이상한 이름이 필요하거나, IsAssignable 같은 짧지만 오해를 부르는 이름을 쓰게 된다.
Member 구성요소 접근에 점 표기법을 사용하지 않기이 PEP 초안의 이전 버전들은 m.name 같은 표기를 지원하지 않았고, 대신 typing.GetName 같은 헬퍼 연산자에 의존했다(이는 내부적으로 typing.GetArg 또는 typing.GetMemberType로 구현할 수 있다).
잠재적 장점은 타입 언어에 추가되는 새로운 구성요소의 수를 줄이고, 연관 타입을 위한 일반 메커니즘을 도입하거나 Member에 대한 특수 처리를 도입할 필요를 피하는 것이다.
쿼리 빌더 예시의 PropsOnly는 다음처럼 보일 것이다:
type PropsOnly[T] = typing.NewProtocol[ *[ typing.Member[typing.GetName[p], PointerArg[typing.GetType[p]]] for p in typing.Iter[typing.Attrs[T]] if typing.IsAssignable[typing.GetType[p], Property] ] ]
다음을 쓰는 대신:
tt if tb else tf*[tres for T in Iter[ttuple]]다음과 같은 타입 연산자 형태를 사용할 수 있다:
Cond[tb, tt, tf]UnpackMap[ttuple, lambda T: tres]UnpackMap[ttuple, T, tres] 여기서 T는 선언된 TypeVar여야 한다불리언 연산도 마찬가지로 연산자(Not, And 등)가 된다.
장점은 타입 애너테이션을 구성할 때 비자명한 계산이 전혀 필요 없다는 점이다(점 표기법도 제거한다고 가정). 따라서 이를 평가하기 위한 런타임 훅이 필요 없다.
또한 원시(raw) 타입 애너테이션을 추출하기가 훨씬 쉬워진다. (lambda 형태는 여전히 다소 까다롭다. non-lambda 형태는 추출이 자명하지만, TypeVar 선언을 요구하는 것은 최근 변화의 흐름에 역행한다.)
또 다른 장점은 특별한 <type-bool> 클래스의 타입 개념이 필요 없다는 것이다.
단점은 문법이 훨씬 나빠 보인다는 점이다. 필터링을 포함해 매핑을 지원하려면 더 나빠질 것이다(필터를 위한 추가 인수?).
필요하다면 다른 옵션도 탐색할 수 있다.
새로운 타입 언어 조각을 정의하는 대신, 타입 조작을 수행하는(일종의 “미니 mypy 플러그인”) Python 함수를 호출할 수 있게 하자는 제안도 있었다.
우리 관점에서의 주요 장점은 더 익숙한 실행 모델을 활용할 수 있다는 것이다.
이는 제안을 단순화할 것이라는 주장도 있지만, 우리는 그러한 단순화가 대부분 신기루라고 보며, Python 함수를 호출해 타입을 조작하는 것은 오히려 훨씬 더 복잡할 것이라고 본다.
타입 체커 내부에서 실행할 수 있는, 잘 정의되고 안전하게 실행 가능한 언어(및 표준 라이브러리) 부분집합을 정의해야 한다. 이런 부분집합은 다른 시스템에서도 정의된 바 있다(예: Bazel의 구성 언어 Starlark), 하지만 표면적 범위가 매우 넓고, 프로그래머는 경계를 항상 의식해야 한다.
또한 “미니 플러그인” 함수에서 타입이 어떻게 표현되는지에 대한 명확한 명세가 필요하고, 다양한 조작을 위한 함수/메서드도 정의해야 한다. 이는 이 PEP가 현재 제안하는 것과 상당히 겹친다.
런타임 사용이 필요하다면, 타입 표현이 현재의 typing 동작과 호환되도록 하거나, 두 가지 런타임 타입 표현을 가져야 한다.
문법이 좋아지는지는 논쟁의 여지가 있다. 우리는 위에서 논의한 문법 정리 아이디어(아직 주요 제안에 통합되지는 않았음) 일부를 채택하는 것이 더 낮은 비용으로 문법을 개선할 것이라고 본다.
이 제안은 TypeScript보다 “엄격하게 타입 지정(strictly-typed)”되어 있지 않다(엄격한 kind라고 해야 할지도 모른다).
TypeScript는 별칭 정의 위치에서 더 나은 타입 체크를 제공한다: P[K]에서 K는 keyof P여야 한다. extends 조건부 타입 연산자는 이를 지원하기 위해 타입을 좁힌다.
우리도 잠재적으로 더 나아질 수 있지만, 훨씬 더 많은 메커니즘이 필요할 것이다.
KeyOf[T] - T의 리터럴 키Member[T]는 예를 들어 다음과 같은 타입을 갖는 것으로 취급할 수 있다:tuple[Member[KeyOf[T], object,
str, ..., ...], ...]
GetMemberType[T, S: KeyOf[T]] - 하지만 이는 아직 지원되지 않는다. TypeScript는 이를 지원한다.FastAPI 같은 라이브러리는 애너테이션을 많이 사용하며, 애너테이션을 이용해 타입 수준 계산에서의 의사결정을 내릴 수 있기를 원한다.
현재 Annotated는 타입 체커에서 완전히 무시될 수도 있으므로, 이를 검사하고 조작하는 지원은 까다로울 수 있다.
한 가지 잠재적 API는 다음과 같다:
GetAnnotations[T] - Annotated일 수 있는 타입의 애너테이션을 Literal로 가져온다. 예시:GetAnnotations[Annotated[int, 'xxx']] = Literal['xxx']
GetAnnotations[Annotated[int, 'xxx', 5]] = Literal['xxx', 5]
GetAnnotations[int] = NeverDropAnnotations[T] - Annotated일 수 있는 타입에서 애너테이션을 제거한다. 예시:DropAnnotations[Annotated[int, 'xxx']] = int
DropAnnotations[Annotated[int, 'xxx', 5]] = int
DropAnnotations[int] = intTypeScript에는 문자열에 대한 “템플릿 리터럴” 타입이 있어 문자열 리터럴 타입의 연결뿐 아니라 분해도 가능하다. 또한 대소문자 관련 연산 모음도 있다.
연결을 지원하면 속성에 기반해 새로운 메서드 이름을 생성하는 등의 사용 사례가 가능해진다: 예를 들어 각 속성 foo에 대해 get_foo 메서드를 생성할 수 있다.
슬라이싱을 지원하면 더 심층적인 문자열 순회가 가능해지고, 대문자화 관련 연산을 지원하면 snake_case에서 CapitalizedWords로 변환하는 같은 작업이 가능하다.
조건부와 여러 연산을 조합하면 case 함수들을 구현할 수는 있지만, 특히 모든 유니코드에 대해 동작하게 하려면 그러지 않는 편이 낫다.
슬라이싱과 연결만을 도입하는 것도 확실히 가능하다.
Slice[S: Literal[str], Start: Literal[int | None], End: Literal[int | None]]: 문자열 타입의 슬라이싱도 지원한다. (현재는 튜플만 지원.)Concat[S1: Literal[str], S2: Literal[str]]: 두 문자열을 연결Uppercase[S: Literal[str]]: 문자열 리터럴을 대문자로Lowercase[S: Literal[str]]: 문자열 리터럴을 소문자로Capitalize[S: Literal[str]]: 문자열 리터럴을 capitalizeUncapitalize[S: Literal[str]]: 문자열 리터럴을 uncapitalize이 섹션의 모든 연산자는 유니온 타입에 대해 리프트된다.
때때로 NewProtocolWithBases 같은 것을 지원하는 것이 유용할 수 있으며, 명세는 다음과 같을 수 있다:
NewProtocolWithBases[Bases: tuple[type], *Ms: Member]아이디어는 어떤 타입이, 주어진 모든 베이스를 확장하고 지정된 멤버를 가지면 이 프로토콜을 만족한다는 것이다.
이는 새로운 Pydantic 모델을 생성하는 것과 같은 상황에서 유용할 수 있다.
현재는 이를 완전히 제안하지 않는데, 베이스를 가진 프로토콜은 프로토콜의 의미를 확장하는 것이며 아직 그 문제에 얽히고 싶지 않고, 또한 많은 사용 사례가 다른 방식으로 시뮬레이션 가능하기 때문이다.
어떤 잘못된 연산은 오류여야 하고 어떤 것은 Never를 반환해야 하는가?
**kwargs를 위한 타입 변수의 Unpack: 바운드로 사용되는 TypedDict에서 추가 인수에 대해 리터럴 타입 추론을 시도할지 여부를 구성 가능하게 해야 하는가? readonly가 TypedDict의 파라미터로 추가되었다면 그것을 썼겠지만, 그렇지 않았다.
확장 Callable: 확장 인수 목록을 typing.Parameters[*Params] 타입으로 감싸야 하는가(이는 ParamSpec의 바운드 역할도 어느 정도 할 것이다)?
확장 Callable: 현재 한정자는 코드 간결성을 위해 짧은 문자열이지만, 대안으로 inspect.Signature를 더 직접적으로 미러링하여 ParamKind.POSITIONAL_OR_KEYWORD 같은 이름을 가진 enum을 둘 수 있다. 그 편이 더 나은가?
관련된 잠재적 변경으로, kind와 default 존재 여부를 완전히 분리하고, default 존재 여부를 Member의 클래스 멤버 초기화자처럼 init 필드로 표현할 수도 있다.
제네릭 Callable: GenericCallable을 검사/분해(destruct)하는 메커니즘이 필요할까? 변수 정보를 가져오고 구체 타입에 적용할 수도 있어야 할까?
클래스 갱신: UpdateClass는 타입 평가 순서 의존성을 도입한다; 어떤 __init_subclass__의 UpdateClass 반환 타입이 무관한 다른 클래스의 Members를 검사하고, 그 클래스도 __init_subclass__를 갖고 있다면, 결과가 평가 순서에 따라 달라질 수 있다. 이상적으로는 이런 경우는 거절되어야 한다. 다만 이는 잠재적인 런타임 평가 순서 의존성과 정확히 대응된다.
제네릭 함수 때문에, 해결되지 않은 타입 변수에 연산자가 적용된 경우 타입 연산자를 평가할 수 없는 사례가 많이 있을 것이며, 그런 경우의 타입 평가 규칙이 무엇이어야 하는지는 다소 불명확하다. 현재 mypy의 개념 증명 구현에서는, 막힌 타입 평가에서 서브타입 검사를 완전 불변(invariant)으로 구현한다: 연산자가 일치하고 각 피연산자가 양쪽 인수에서 불변적으로 일치하는지 확인한다.
설계에 대해 많은 논의를 해준 Jukka Lehtosalo에게 감사한다.
또한 이 제안에 큰 영향을 준 TypeScript 팀에도 감사한다!
[1]
이 문서는 퍼블릭 도메인에 두거나, 더 관대한 쪽을 택해 CC0-1.0-Universal 라이선스로 제공된다.
Source: https://github.com/python/peps/blob/main/peps/pep-0827.rst
Last modified: 2026-03-02 18:44:40 GMT