typing 본격 — Generic, Protocol, TypedDict, Literal
기초의 타입 힌트 다음 단계 — 타입을 매개변수화하는 Generic, 덕 타이핑을 정확히 적는 Protocol, dict 모양 명시 TypedDict, 좁은 union Literal까지 정리합니다.
8장 dataclass와 __slots__에서 데이터 모양을 짧게 적는 도구를 봤다면, 본 챕터는 그 모양을 더 정확하고 표현력 있게 적는 도구들입니다. typing 모듈의 본격 무기 네 개 — Generic, Protocol, TypedDict, Literal을 다룹니다.
본 챕터의 네 도구는 책 뒷부분의 거의 모든 곳에서 다시 만납니다. Protocol은 15장 매직 메소드 깊이와 프로토콜의 표면 위에 올라가는 추상이고, Generic은 20장 typing 고급 — Variance, ParamSpec, Self, overload의 출발점입니다. TypedDict와 Literal의 결합은 4부 FastAPI에서 24장 Pydantic v2 깊이의 discriminated union으로 다시 등장합니다.
출발점 — 어디까지가 기초였나 #
2장 변수, 기본 타입과 타입 힌트에서 다음까지 잡혔습니다.
int,str,bool,Nonelist[int],dict[str, int]int | None(union 단축)type Alias = int(타입 별칭)Callable[[int, int], int]
다음 단계 — 사용자가 매개변수를 채울 수 있는 타입부터 시작합니다.
Generic — 타입 매개변수 #
같은 모양인데 안에 들어가는 타입만 다른 함수 / 클래스를 적고 싶을 때.
함수 — 입력 타입 그대로 반환 #
def first(items: list) -> object:
return items[0]
x = first([1, 2, 3]) # x: object ← int 인 정보를 잃어버림first([1,2,3])은 분명히 int를 반환하지만, 시그니처가 object 라 에디터가 그 정보를 잃습니다. 매개변수화가 필요합니다.
def first[T](items: list[T]) -> T:
return items[0]
x = first([1, 2, 3]) # x: int
y = first(["a", "b"]) # y: strdef first[T] 형태가 Python 3.12의 새 문법입니다. PEP 695. 이전에는 다음과 같이 적었습니다.
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]새 코드는 메소드 / 함수 옆 [T] 위치에 직접 쓰는 방식으로 통일하면 됩니다.
여러 타입 매개변수 #
def pair[K, V](key: K, value: V) -> tuple[K, V]:
return (key, value)
p = pair("name", 42) # tuple[str, int]타입 제약 — bound / 명시적 제한
#
class HasLength:
def __len__(self) -> int: ...
def shortest[T: HasLength](items: list[T]) -> T:
return min(items, key=len)[T: HasLength]는 “T는 HasLength의 서브타입이어야 한다"는 제약입니다. 사실 위 경우는 Protocol이 더 어울립니다. (아래에서 다룸)
def add[T: (int, float)](a: T, b: T) -> T:
return a + b
add(1, 2) # int
add(1.0, 2.0) # float
add(1, 2.0) # ✗ T 가 한 타입으로 결정 안 됨[T: (int, float)] 형태는 “T는 int 또는 float“를 의미합니다. 제네릭 제약 (constraint)이라고 부릅니다.
클래스 — Generic class #
class Stack[T]:
def __init__(self):
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T | None:
if not self._items:
return None
return self._items.pop()
s: Stack[int] = Stack()
s.push(1)
s.push(2)
x = s.pop() # x: int | NoneStack[int]처럼 사용 시점에 T를 채워서 쓰는 것이 제네릭 클래스의 사용 패턴입니다. Variance(공변·반공변·불변) 같은 주제는 20장 typing 고급에서 다룹니다.
Protocol — 덕 타이핑을 타입으로 #
파이썬 관용구는 **“어떤 클래스 인스턴스인가"가 아니라 “어떤 메소드 / 속성을 가지는가”**입니다. 이걸 정적 타입으로 표현하는 게 **Protocol**입니다.
자바 / C# 의 인터페이스가 아님 #
자바 인터페이스는 명시적으로 implements를 적어야 그 인터페이스가 됩니다. 파이썬 Protocol은 그게 필요 없습니다. 모양만 맞으면 그 Protocol을 충족합니다 (structural typing).
from typing import Protocol
class Closable(Protocol):
def close(self) -> None: ...
# 무엇이 Closable 인가?
def safe_close(resource: Closable) -> None:
resource.close()
# 위 함수는 .close() 를 가진 모든 객체를 받음.
# 명시적인 상속 관계가 필요 없음.
class File:
def close(self) -> None:
print("file closed")
class Connection:
def close(self) -> None:
print("connection closed")
safe_close(File()) # OK — File 은 Closable 을 충족
safe_close(Connection()) # OK — Connection 도 충족runtime_checkable — 런타임에 isinstance 가능
#
기본 Protocol은 정적 검사용입니다. isinstance로 검사하려면 데코레이터를 붙입니다.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None: ...
print(isinstance(File(), Closable)) # True다만 isinstance는 메소드 이름만 검사 하고 시그니처는 안 봅니다. 정확한 검사는 정적 타입 체커가 더 잘합니다.
Protocol이 유용한 지점 — “원하는 만큼만 약속” #
from typing import Protocol
class Sized(Protocol):
def __len__(self) -> int: ...
def first_n[T](items: Sized, n: int) -> ...:
if len(items) < n:
raise ValueError("부족함")
...함수가 필요한 건 __len__ 메소드만. 그래서 인자 타입을 “리스트"가 아니라 “len 가능한 무언가"로 표현하는 게 더 정확하고 유연합니다. 호출 측이 list / tuple / dict / 사용자 정의 클래스 무엇이든 통과합니다.
collections.abc가 이미 가진 Protocol
#
표준 라이브러리에 자주 쓰는 Protocol 들이 이미 있습니다.
from collections.abc import (
Iterable, Iterator, Sized, Container,
Mapping, Sequence, Callable, Hashable
)
def process(items: Iterable[int]) -> None:
for x in items:
...
def lookup(m: Mapping[str, int], key: str) -> int | None:
return m.get(key)collections.abc는 abstract base class 지만 Protocol처럼 동작하기도 해서 (@runtime_checkable 됨), 표준 형태는 여기서 가져다 쓰면 됩니다. 직접 Protocol을 정의하는 건 표준에 없는 모양일 때.
TypedDict — dict의 모양을 타입으로 #
JSON 같은 데이터를 dict로 다룰 때, 어떤 키와 어떤 값 타입이 들어가는지 적고 싶을 때.
from typing import TypedDict
class User(TypedDict):
id: int
name: str
age: int
u: User = {"id": 1, "name": "커티스", "age": 30}
print(u["name"]) # name: str — 타입이 좁혀짐TypedDict는 런타임에는 그냥 dict입니다. 인스턴스 검사를 해도 dict로 보입니다. 타입 체커만 알아봅니다.
dataclass와 차이 #
| dataclass | TypedDict | |
|---|---|---|
| 런타임 형태 | 사용자 정의 클래스 인스턴스 | 일반 dict |
| 메소드 | __init__, __repr__, __eq__ 자동 | 없음 (dict 메소드만) |
| 접근 | u.name | u["name"] |
| 어울리는 용도 | 내부 도메인 모델 | JSON, 외부 API 응답 |
API 응답을 받아서 dict로 다루는 경우에 TypedDict가 어울립니다. 변환 비용이 없습니다 — 그냥 들어온 dict를 그 모양이라고 선언만 하면 됩니다.
옵션 키 — total=False / NotRequired
#
from typing import TypedDict, NotRequired
class User(TypedDict):
id: int # 필수
name: str # 필수
nickname: NotRequired[str] # 선택
u1: User = {"id": 1, "name": "커티스"} # OK
u2: User = {"id": 1, "name": "커티스", "nickname": "C"} # OK옛 방식은 클래스 단위 total=False 인데, 모든 키를 옵션으로 만들어버려서 거의 안 씁니다. 새 코드는 **NotRequired**가 표준입니다.
반대로, 모두 옵션인 dict에서 일부만 필수로 만들고 싶으면 Required:
from typing import TypedDict, Required
class Config(TypedDict, total=False):
timeout: int
retries: int
base_url: Required[str] # 이것만 필수상속도 됨 #
class BaseUser(TypedDict):
id: int
name: str
class AdminUser(BaseUser):
permissions: list[str]AdminUser는 id, name, permissions 세 키를 가집니다.
Literal — 좁은 union #
값 자체를 타입으로. 특정 문자열 / 숫자만 허용하고 싶을 때.
from typing import Literal
def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
...
set_log_level("info") # OK
set_log_level("trace") # ✗ 허용된 4개에 없음호출 측 자동완성이 정확히 나오고, 오타가 컴파일 타임에 잡힙니다.
Literal과 union #
type Color = Literal["red", "green", "blue"]
type Mode = Literal["light", "dark", "auto"]
def render(color: Color, mode: Mode) -> str:
...상수와 결합 — Final
#
from typing import Final, Literal
DEFAULT_LEVEL: Final = "info"
# DEFAULT_LEVEL 의 타입이 Literal["info"] 로 좁혀짐
DEFAULT_LEVEL = "warn" # ✗ Final — 재대입 불가Final은 “이 변수는 한 번만 대입"을 표시합니다. 모듈 상수에 자주 씁니다.
Discriminated union — 결정적 분기 #
서로 다른 모양의 dict가 한곳에 들어올 때, “어떤 모양인지"를 한 키로 식별하는 패턴.
from typing import Literal, TypedDict
class ClickEvent(TypedDict):
type: Literal["click"]
x: int
y: int
class KeyEvent(TypedDict):
type: Literal["key"]
code: str
type Event = ClickEvent | KeyEvent
def handle(event: Event) -> None:
if event["type"] == "click":
# 여기서 event 는 ClickEvent 로 좁혀짐
print(event["x"], event["y"])
else:
# 여기는 KeyEvent
print(event["code"])type 키의 Literal 값을 보고 타입 체커가 자동으로 좁혀줍니다. 3장 제어 흐름의 match-case와 잘 어울리는 패턴이고, 13장 패턴 매칭 깊이에서 본격적으로 다시 다룹니다.
다른 자주 쓰는 도구들 #
Optional은 이제 안 씀
#
# 옛
from typing import Optional
def find(id: int) -> Optional[str]: ...
# 새 — 항상 이쪽
def find(id: int) -> str | None: ...Any는 마지막 수단
#
Any는 “타입 체크를 끄겠다"라는 선언입니다. 진짜로 모르겠을 때만 쓰고, object / Unknown (pyright) / unknown 같은 좁은 타입으로 갈 수 있는지 먼저 검토하세요.
Self — 메소드 반환에서 자기 클래스
#
from typing import Self
class Builder:
def __init__(self):
self.items: list[str] = []
def add(self, item: str) -> Self:
self.items.append(item)
return self-> Self는 “이 메소드는 호출된 클래스의 인스턴스를 반환"을 의미합니다. 빌더 패턴에 잘 어울립니다. 옛 방식은 -> "Builder" (forward reference) 였는데 서브클래스로 호출되면 정확하지 않았습니다. Self가 그걸 풀어줍니다. 20장 typing 고급에서 좀 더 자세히 보겠습니다.
연습문제 #
def last[T](items: list[T]) -> T | None:시그니처의 함수를 작성하세요.last([1, 2, 3])은int | None,last(["a", "b"])는str | None으로 추론되는지 pyright로 확인합니다.Protocol로Drawable(def draw(self) -> str: ...)를 정의하고,def render_all(items: list[Drawable]) -> list[str]:를 작성하세요. 상속 없이draw메소드만 가진 클래스 두 개를 만들어서render_all에 그 인스턴스 리스트를 넘겨 정상 동작하는 것을 확인합니다.TypedDict+Literal로 discriminated union을 만드세요.ClickEvent(type: Literal["click"],x: int,y: int) /KeyEvent(type: Literal["key"],code: str) 두 타입을 합쳐Event = ClickEvent | KeyEvent로 만들고,if event["type"] == "click":분기 안에서event["x"]접근이 타입 에러 없이 동작하는지 확인합니다.
한 줄 요약: Generic은
def fn[T](...)/class Stack[T]:로 타입 매개변수화. Protocol은 명시적 implements 없이 모양으로 약속 —collections.abc가 표준 모음. TypedDict는 dict 모양 선언으로 JSON / 외부 데이터에 적합 (NotRequired/Required). Literal은 값을 타입으로 좁히기, discriminated union의 핵심.Optional은 폐기,Any는 마지막 수단,Self는 빌더 패턴.
다음 챕터 #
다음 10장 컨텍스트 매니저 (with, contextlib)에서는 자원 관리의 표준 도구 — with 문과 contextlib의 모든 패턴을 다룹니다. 6장 예외 처리에서 본 finally 정리 코드를 더 안전하고 읽기 쉽게 표현하는 방법입니다.