目次
20 章

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)TU のサブタイプなら Box[T]Box[U] のサブタイプtuple[T, ...]Iterable[T] (read-only)
反変 (contravariant)TU のサブタイプなら Box[U]Box[T] のサブタイプCallable[[T], R]引数位置

直感 #

  • 読むだけ → 共変。Iterable[Cat]Iterable[Animal] として使用可能 (読めば Animal である Cat)
  • 書き込み / 読み込みの両方 → 不変。list[Cat]dict[K, V]
  • 引数位置 → 反変。「Animal を受け取る関数」の役割に「Cat を受け取る関数」を入れると危険 (Dog が来ても Animal 関数は受け取らねば)

関数型で見ると #

Callable の variance
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: ...

新しいコードは通常、直接明示しなくてもツールがうまく処理します。ライブラリ作成者なら IterableCallable のような標準 ABC の variance を意識して使用 — その程度が日常的な使用範囲です。

ParamSpecConcatenate #

第12章 デコレータパターン でデコレータのシグネチャを保存する道具として簡単に見ました。より深く。

ParamSpec の正体 #

ParamSpec 再び
def log[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        ...
    return wrapper

P型パラメータではなく、パラメータシグネチャそのもの です。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章 で簡単に見た道具。

Self
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 — 同じ関数、異なるシグネチャ #

呼び出し引数によって 戻り値の型が変わる 関数に正確な型をつける道具。

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 引数によって分岐するとき
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 | Noned.get("k", 0)int (0 の型)、同じ呼び出しの正確な推論が可能になるパターンです。

TypeGuardTypeIs — 型を絞り込む関数 #

isinstance 以外に ユーザー定義の型絞り込み関数 を作る道具。

TypeGuard (3.10+) #

TypeGuard
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 の改良版 #

TypeIs
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 — 型にメタデータ #

Annotated
from typing import Annotated

UserId = Annotated[int, "UserID — DB の users.id"]
Email = Annotated[str, "メール形式"]

def find(id: UserId, email: Email): ...

Annotated[T, ...] の追加メタデータは型チェッカが 無視 します。ただしライブラリが ランタイムにそれを読んで動作に活用 できます。FastAPIDependsQuery が本パターンを使う最も有名な例です。

FastAPI が使うパターン
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)],
): ...

型自体は strDatabase のようにそのまま読めますが、追加の動作仕様もまとめて持てます。第24章 Pydantic v2 の深さ と第23章 ルーティング、Pydantic モデル、依存性注入 で詳しく扱います。

LiteralString — SQL インジェクション防御 (3.11+) #

LiteralString
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 — 同じ形、異なる型 #

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+) #

TypeVarTuple
def stack[*Ts](*args: *Ts) -> tuple[*Ts]:
    return args

t = stack(1, "hello", 3.14)
# t の型: tuple[int, str, float]

可変個の型パラメータ — numpy のような多次元配列ライブラリで次元を型として表現するときに活用。

castassert_typeassert_never #

cast — 型を強制 #

cast
from typing import cast

raw: object = get_data()
data = cast(dict[str, int], raw)
# ランタイムにはただの raw、型チェッカだけが dict[str, int] として見る

確実に知っているが チェッカが推論できないとき に使う最後の手段。誤って使うとランタイム事故が起こり得るので 理由のコメント を一緒に置くのが良いです。

assert_type — 推論を検証 #

assert_type
from typing import assert_type

x = some_function()
assert_type(x, int)    # チェッカが x を int に推論しないとエラー

型チェッカに「この地点は正確にこの型でなければならない」と断言。ライブラリのテストでよく使われます。

assert_never — すべてのケースの処理を保証 #

exhaustiveness
from typing import assert_never

def handle(event: Literal["click", "key"]):
    if event == "click": ...
    elif event == "key": ...
    else:
        assert_never(event)    # 新しいケースが増えるとここが壊れる

union のすべてのケースを処理したかを チェッカが検証 してくれます。discriminated union の分岐に有用です。

練習問題 #

  1. Concatenate でデコレータを作ってください。@with_db が引数に db: Database を自動注入して、呼び出し側は db を書かないようにします。シグネチャが pyright で正確に推論されるか確認します。
  2. @overloaddef parse(value) 関数を作成してください。strstrintintlist[str]list[str] の 3 つのオーバーロードを定義し、本体は単一。result = parse(42)result の推論型が int か確認します。
  3. TypeIsis_str_list(items: list[object]) -> TypeIs[list[str]] を作成してください。if is_str_list(data): の中で datalist[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、メモリプロファイリングまで扱います。

X