typing 上級 — Variance、ParamSpec、Self、overload
中級 typing の次の段階として、covariance/contravariance、ParamSpec と Concatenate、Self、TypeGuard/TypeIs、@overload までまとめます。
第9章 typing 本格 で Generic、Protocol、TypedDict、Literal まで押さえました。本章はその次の段階で、typing システムの難しい部分 を扱います。variance、ParamSpec / Concatenate、Self、TypeGuard / TypeIs、@overload といった道具です。
本章はほぼ ライブラリ作成者に必要な領域 で、一般のコードには出てこなくても構いません。しかし、ライブラリコードを読んだり正確な型を作りたい場合は知っておくべきです。第4部の第24章 Pydantic v2 の深さ で、本章の Annotated が FastAPI の中核ツールとして再登場します。
Variance — 共変性 / 反変性 #
最もややこしい部分から。list[Cat] は list[Animal] のサブタイプか?
直感的に「Cat が Animal だから list[Cat] も list[Animal] でしょ」と考えがちですが — 違います。 なぜか見てみましょう。
def add_dog(animals: list[Animal]) -> None:
animals.append(Dog())
cats: list[Cat] = [Cat(), Cat()]
add_dog(cats) # ✗ もし許されると、cats の中に Dog が入るlist[Cat] は list[Animal] では ありません。 受け取る側と渡す側がどちらも可能 (mutable) なので、どちら側でも安全を保証しにくいのです。
3 つの variance #
| 種類 | 意味 | 例 |
|---|---|---|
| 不変 (invariant) | Box[T] と Box[U] は無関係 | list[T] |
| 共変 (covariant) | T が U のサブタイプなら Box[T] も Box[U] のサブタイプ | tuple[T, ...]、Iterable[T] (read-only) |
| 反変 (contravariant) | T が U のサブタイプなら Box[U] が Box[T] のサブタイプ | Callable[[T], R] の 引数位置 |
直感 #
- 読むだけ → 共変。
Iterable[Cat]はIterable[Animal]として使用可能 (読めば Animal である Cat) - 書き込み / 読み込みの両方 → 不変。
list[Cat]、dict[K, V] - 引数位置 → 反変。「Animal を受け取る関数」の役割に「Cat を受け取る関数」を入れると危険 (Dog が来ても Animal 関数は受け取らねば)
関数型で見ると #
def feed_animal(a: Animal): ...
def feed_cat(c: Cat): ...
# 'Animal を受け取る関数' の位置に 'Cat だけを受け取る関数' を入れられるか?
fn: Callable[[Animal], None] = feed_cat # ✗ — Dog が来ると壊れる
# 逆は?
fn2: Callable[[Cat], None] = feed_animal # ✅ — Cat も Animal の一部なので OK関数の 引数位置は反変 です。戻り値の位置は共変です。
どうやって variance を明示するか #
第9章 で見た PEP 695 の新構文 def fn[T]: は 自動で推論 します。古い方式 (TypeVar) では直接書きました。
from typing import TypeVar
T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)
class Producer(Generic[T_co]):
def get(self) -> T_co: ...
class Consumer(Generic[T_contra]):
def put(self, item: T_contra) -> None: ...新しいコードは通常、直接明示しなくてもツールがうまく処理します。ライブラリ作成者なら Iterable、Callable のような標準 ABC の variance を意識して使用 — その程度が日常的な使用範囲です。
ParamSpec と Concatenate
#
第12章 デコレータパターン でデコレータのシグネチャを保存する道具として簡単に見ました。より深く。
ParamSpec の正体
#
def log[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
...
return wrapperP は 型パラメータではなく、パラメータシグネチャそのもの です。1 束の (位置引数の型たち + キーワード引数の型たち) を表現します。
Concatenate — 引数を追加
#
デコレータが 引数を 1 つ追加 したいとき。
from typing import Concatenate, Callable
def with_logger[**P, R](
fn: Callable[Concatenate[Logger, P], R]
) -> Callable[P, R]:
logger = make_logger()
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(logger, *args, **kwargs)
return wrapper
@with_logger
def do_work(logger: Logger, x: int, y: int) -> int:
logger.info(f"{x} + {y}")
return x + y
do_work(2, 3) # logger は自動注入Concatenate[Logger, P] の意味: 「最初の引数が Logger、その後は P そのまま」。デコレートされた後は logger 引数が抜けた シグネチャになります。
デコレータが 引数を抜いて 呼び出し側に書かせない pattern — 依存性注入の一形態です。第23章 ルーティング、Pydantic モデル、依存性注入 の FastAPI Depends がこの役割を担います。
Self — メソッドの戻り値で自クラス
#
第9章 で簡単に見た道具。
from typing import Self
class Builder:
def add(self, item: str) -> Self:
...
return self
class SubBuilder(Builder):
def special(self) -> Self:
...
return self
b = SubBuilder().add("x").special()
# b の型は正確に SubBuilder に推論される-> "Builder" (forward reference) を使うと、SubBuilder で呼び出しても結果の型が Builder になって .special() の呼び出しが阻まれます。Self がそれを解いてくれます。
クラスメソッドにも #
class Item:
@classmethod
def from_dict(cls, data: dict) -> Self:
return cls(**data)サブクラスで呼び出すと結果の型がサブクラスに正確に推論されます。
@overload — 同じ関数、異なるシグネチャ
#
呼び出し引数によって 戻り値の型が変わる 関数に正確な型をつける道具。
from typing import overload
@overload
def parse(value: str) -> str: ...
@overload
def parse(value: int) -> int: ...
@overload
def parse(value: list[str]) -> list[str]: ...
def parse(value):
# 実際の実装 — overload シグネチャの下
return value
a = parse("hello") # str
b = parse(42) # int
c = parse(["a", "b"]) # list[str]@overload デコレータがついた定義は 型検査専用 です。本体は通常 ...。その下に 実際の本体が 1 つ 来ます。
いつ使うか? #
- 引数の型によって戻り値の型が変わるとき
- Literal 引数によって分岐するとき
@overload
def get(key: Literal["count"]) -> int: ...
@overload
def get(key: Literal["name"]) -> str: ...
@overload
def get(key: str) -> object: ...
def get(key): ...呼び出し側が get("count") なら正確に int を受け取ると推論されます。
dict.get のような標準関数がこのように定義されている #
@overload
def get(self, key: K) -> V | None: ...
@overload
def get(self, key: K, default: V) -> V: ...
@overload
def get(self, key: K, default: T) -> V | T: ...d.get("k") → V | None、d.get("k", 0) → int (0 の型)、同じ呼び出しの正確な推論が可能になるパターンです。
TypeGuard と TypeIs — 型を絞り込む関数
#
isinstance 以外に ユーザー定義の型絞り込み関数 を作る道具。
TypeGuard (3.10+)
#
from typing import TypeGuard
def is_str_list(items: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in items)
def process(data: list[object]) -> None:
if is_str_list(data):
# ここで data は list[str] に絞り込まれる
print(", ".join(data))TypeGuard[T] を返す関数は True のときに引数が T という約束になります。
TypeIs (3.13+) — TypeGuard の改良版
#
from typing import TypeIs
def is_str(x: object) -> TypeIs[str]:
return isinstance(x, str)
def handle(x: int | str) -> None:
if is_str(x):
print(len(x)) # str
else:
print(x + 1) # int ← ここが違う!違い: TypeIs は False のときも 型を絞り込みます。TypeGuard は True のときだけ。TypeIs がより直感的で安全な場合に使われます。
TypeGuard は互換性維持のために残っており、新しいコードでは TypeIs が推奨されます (3.13+)。
Annotated — 型にメタデータ
#
from typing import Annotated
UserId = Annotated[int, "UserID — DB の users.id"]
Email = Annotated[str, "メール形式"]
def find(id: UserId, email: Email): ...Annotated[T, ...] の追加メタデータは型チェッカが 無視 します。ただしライブラリが ランタイムにそれを読んで動作に活用 できます。FastAPI の Depends、Query が本パターンを使う最も有名な例です。
from typing import Annotated
from fastapi import Depends, Query
def get_db(): ...
def search(
q: Annotated[str, Query(min_length=3)],
db: Annotated[Database, Depends(get_db)],
): ...型自体は str、Database のようにそのまま読めますが、追加の動作仕様もまとめて持てます。第24章 Pydantic v2 の深さ と第23章 ルーティング、Pydantic モデル、依存性注入 で詳しく扱います。
LiteralString — SQL インジェクション防御 (3.11+)
#
from typing import LiteralString
def execute(query: LiteralString) -> list[Row]:
...
execute("SELECT * FROM users WHERE id = 1") # OK
execute(f"SELECT * FROM users WHERE id = {user_input}") # ✗ — f-string の結果は LiteralString ではないLiteralString は コンパイル時点で知られている文字列 だけを受け取る型です。ユーザー入力で作った文字列 (f"..."、+ user_id など) は拒否されます。SQL インジェクション防御を静的に保証する道具。
NewType — 同じ形、異なる型
#
from typing import NewType
UserId = NewType("UserId", int)
ProductId = NewType("ProductId", int)
def get_user(id: UserId): ...
uid = UserId(123)
pid = ProductId(456)
get_user(uid) # OK
get_user(pid) # ✗ — 異なる型として扱われる
get_user(123) # ✗ — int そのままでは入れられないNewType は ランタイムにはただの int です。型チェッカだけが異なる型として扱う。同じ int でも意味が異なる場合 (UserId vs ProductId、USD vs KRW) に強力です。
type 別名との違い
#
第2章 変数、基本型と型ヒント の type 別名 (type UserId = int) は ただの別名 — int の位置に UserId を入れられて、その逆も可能。NewType は一方向だけ: int → UserId は明示的なキャストが必要、自動ではなりません。
type 別名は 名前だけ違う、NewType は 異なる型に分離。
Generic class — 再度深く #
第9章 で見た class Stack[T]:。さらに踏み込むと。
複数のパラメータ #
class Cache[K, V]:
def __init__(self):
self._data: dict[K, V] = {}
def get(self, key: K) -> V | None: ...
def put(self, key: K, value: V) -> None: ...
cache: Cache[str, int] = Cache()制約と bound #
class SortedList[T: (int, str)]: # int または str のみ
...
class Container[T: Comparable]: # Comparable のサブタイプのみ
...可変長 — TypeVarTuple (3.11+)
#
def stack[*Ts](*args: *Ts) -> tuple[*Ts]:
return args
t = stack(1, "hello", 3.14)
# t の型: tuple[int, str, float]可変個の型パラメータ — numpy のような多次元配列ライブラリで次元を型として表現するときに活用。
cast、assert_type、assert_never
#
cast — 型を強制
#
from typing import cast
raw: object = get_data()
data = cast(dict[str, int], raw)
# ランタイムにはただの raw、型チェッカだけが dict[str, int] として見る確実に知っているが チェッカが推論できないとき に使う最後の手段。誤って使うとランタイム事故が起こり得るので 理由のコメント を一緒に置くのが良いです。
assert_type — 推論を検証
#
from typing import assert_type
x = some_function()
assert_type(x, int) # チェッカが x を int に推論しないとエラー型チェッカに「この地点は正確にこの型でなければならない」と断言。ライブラリのテストでよく使われます。
assert_never — すべてのケースの処理を保証
#
from typing import assert_never
def handle(event: Literal["click", "key"]):
if event == "click": ...
elif event == "key": ...
else:
assert_never(event) # 新しいケースが増えるとここが壊れるunion のすべてのケースを処理したかを チェッカが検証 してくれます。discriminated union の分岐に有用です。
練習問題 #
Concatenateでデコレータを作ってください。@with_dbが引数にdb: Databaseを自動注入して、呼び出し側はdbを書かないようにします。シグネチャが pyright で正確に推論されるか確認します。@overloadでdef parse(value)関数を作成してください。str→str、int→int、list[str]→list[str]の 3 つのオーバーロードを定義し、本体は単一。result = parse(42)でresultの推論型がintか確認します。TypeIsでis_str_list(items: list[object]) -> TypeIs[list[str]]を作成してください。if is_str_list(data):の中でdataがlist[str]に絞り込まれるか、else:分岐でlist[object]のまま残るか (TypeGuard との違い) 確認します。
一行まとめ: Variance は invariant (list) / covariant (Iterable、読み込み) / contravariant (Callable 引数)。
ParamSpec+Concatenateでデコレータのシグネチャ保存 + 引数注入。Selfはメソッドの戻り値で自クラス、@overloadで引数によって戻り値の型を分岐。TypeGuard/TypeIs(3.13+) でユーザー定義の絞り込み。Annotatedは FastAPI / Pydantic の核心。LiteralStringで SQL インジェクション防御、NewTypeで同じ形の異なる型。cast/assert_type/assert_neverで明示的な断言。
次の章 #
次の 第21章 パフォーマンス — cProfile、py-spy、メモリプロファイリング が第3部の最後 — 遅いコードを見つけて直す道具、cProfile、py-spy、line_profiler、メモリプロファイリングまで扱います。