grasys blog

Python 型ヒントの書き方 – 基本からジェネリクスまで

1. はじめに:このコードの出力結果は?

まずは、以下のコードの出力結果は1から3のどれになるか考えてみてください。

def half_count(n):
    return n / 2

half = half_count(10)
print("⭐" * half)
  1. ⭐ * 5
  2. ⭐⭐⭐⭐⭐
  3. TypeError

答えは3のTypeErrorです。 n/ 2の返り値はfloat型になります。float型をstr型に掛けることができないためエラーになります。

このコードに型ヒントをつけると

def half_count(n: int) -> float:
    return n / 2

half = half_count(10)
print("⭐" * half)

になり、型チェッカーが

Unsupported operand types for * (“str” and “float”) [operator]

として、エラーを通知します。さらに、エディタで型チェッカーを有効にしていると、以下の画像のように実装中にリアルタイムで型エラーを検知・通知してくれます。

この様に、型ヒントを使うことで、関数の実装者が想定している入出力の型の理解がしやすくなり、エラーの早期発見もできるメリットがあります。

型ヒントは使うとメリットが大きいですが、型ヒントを書かなくても動作するため、後回しにされがちです。

そこで、今回はよく使われる型ヒントを中心に型ヒントの書き方についてご紹介します。

2. 型ヒントの書き方 – どこにどう書く?

型ヒントを書く場所は、大きく分けて 「変数」 と 「関数」 の2箇所です。

変数の型ヒント

変数名の後ろに 「 : 」 を付けて「変数名: 型 」の形式で書きます。ただし、以下の例の「 address = “渋谷区”」のように、代入される値から型が決まる場合は型ヒントを省略することができます。

address_list: list[str] = []    # 配列の中に登録できる型を固定
address = "渋谷区"               # 代入される値の型から自動で型が推論される!
address_list.append(address)    # OK!
address_list.append(1)          # NG! type エラー

関数の型ヒント

関数の 「引数」 と 「返り値」 に指定します。

def scale_size(size: float, ratio: float) -> float:
    return size * ratio

scale_size(1, 2)               # OK! floatにintを入れるのは問題なし
scale_size(1j, 2)              # NG! 複素数を入れても計算的には問題ないですが型エラー

3. コンテナの型 – リストや辞書を扱う

コンテナの型ヒントを書くときは、単にlistやtuple、dictであることを定義するのではなく、そのコンテナの中にどのような型の値が入っているかを指定することが重要です。

list

list[中身の型]の形式で記述します。中身の型にはstrやint、floatだけでなくdate型等、listの中に入る型を何でも記述できます。

from datetime import date

places: list[str] = ["渋谷", "恵比寿", "目黒"]
date_list: list[date] = [date(2025, 12, 24), date(2025, 12, 25)]

tuple

tupleはtuple[要素の型]の形式で記述しますが、tupleの要素を変更できないという特性から、要素の数だけ要素の型を定義する必要があります。ただし、全てが同じ型の要素であればtuple[要素の型, …]の形式で要素数を可変として型ヒントを記述できます。

places: tuple[str, str, str] = ("渋谷", "恵比寿", "目黒")
places: tuple[str, ...] = ("渋谷", "恵比寿", "目黒", "五反田")
place_fee: tuple[str, int] = ("恵比寿", 146)

dict

dictはdict[キーの型, 値の型]の形式で記述します。dict型の値にdict型を指定するときは、dict[キーの型, dict[キーの型, 値の型]]と入れ子にして記述します。

place_free_map: dict[str, int] = {"恵比寿": 146, "目黒": 167}
yamanote_line_place_free_map: dict[str, dict[str, int]] = {"渋谷": {"恵比寿": 146, "目黒": 167}}

List, Tuple, Dict, etc…

Pythonのライブラリの実装を見ると、以下の例のように、listの型ヒントがList (Lが大文字)となっていたり、tupleがTupleになっていたりすることがあります。これは、組み込み型がそのまま型ヒントとして利用できなかったPython3.8以前をサポートするためです。Python3.9移行のバージョンでは組み込み型を型ヒントとして使えるかつ、Python3.9までEnd of Lifeを迎えているため、組み込み型を型ヒントにそのまま使うようにしましょう。

from typing import List
places: List[str] = ["渋谷", "恵比寿", "目黒"]   # Python3.8以前の書き方
places: list[str] = ["渋谷", "恵比寿", "目黒"]   # Python3.9以降の書き方

4. typingモジュールの活用 – 表現力を高める特殊な型

① 「決まった値だけ」に制限する (Literal)

引数に設定できる値を手軽に特定の値に固定したいときには、Literalが使えます。例えば、hashのアルゴリズムとしてsha256とmd5だけサポートしている関数を実装するときに、hashアルゴリズムを選択できるようにすると以下のようになります。

import hashlib
from typing import Literal

def calculate_checksum(data: bytes, algorithm: Literal["sha256", "md5"]) -> str:
    """指定されたハッシュアルゴリズムを適用し、16進数の文字列を返す"""
    
    match algorithm:
        case "sha256":
            return hashlib.sha256(data).hexdigest()
        case "md5":
            return hashlib.md5(data).hexdigest()

calculate_checksum("sample".encode(), algorithm="sha256")
calculate_checksum("sample".encode(), algorithm="md5")

このようにすることで、algorithmの値に何が設定できるのかを明示することができ、関数の利用者が実装を知らなくても、関数定義をみるだけで、checksumの計算でサポートされているアルゴリズムがsha256とmd5の2つということを簡単に理解することができます。

② 「この型か、この型のいずれか」を指定する (Union)

型に複数の候補があるときは以下の例のようにUnion演算子「 | 」を使用します。

from datetime import date

def format_date(d: date | str) -> str:
    """dateオブジェクト、または文字列を受け取って整形する"""
    if isinstance(d, str):
        # 文字列ならdateオブジェクトに変換する
        d = date.fromisoformat(d)
    return d.strftime("%Y年%m月%d日")

format_date("2025-12-25")        # OK! 2025年12月25日
format_date(date(2025, 12, 25))  # OK! 2025年12月25日
format_date("昨日")               # ValueError! unionは多用しない方がトラブルが少ない

date | strとすることでdate型またはstr型と型ヒントをつけることができます。ただし、複数の型を受け入れると、型の違いにより曖昧さが発生してトラブルにつながりやすいため、どうしても必要なときだけ使うようにします。

③ 「値がない(None)かもしれない」を指定する

Union演算子が推奨として使われる例として、Optionalがあります。「 str | None = None 」と表現することで引数を未指定にすることができるという意図を型ヒントとして明示できます。Python3.9以前ではUnion演算子が使用できなかったため、「Optional[str] = None」と記述していましたが、Python3.10以降はUnion演算子を使用して 「| None」とします。例えば、以下の例のように日付の文字列表現のフォーマットを指定することもできるということを表現するためにoptional表現が使えます。

from datetime import date
from typing import Literal

def get_formatted_date(d: date, style: Literal["年月日", "slash"] | None = None) -> str:
    """指定されたスタイルで日付を整形する

    無指定(None)またはその他の場合は ISO形式を返す
    """
    match style:
        case "年月日":
            return d.strftime("%Y年%m月%d日")
        case "slash":
            return d.strftime("%Y/%m/%d")
        case _:
            return d.isoformat()

get_formatted_date(date(2025, 12, 25), style="年月日")  # 2025年12月25日
get_formatted_date(date(2025, 12, 25), style="slash")  # 2025/12/25
get_formatted_date(date(2025, 12, 25))                 # 2025-12-25

④ 「どんな型でもOK」にする (Any)

型を限定できない、または、限定したくない場合が実用上は発生します。このような場合に、あらゆる型を受け入れられるAnyを使用します。たとえば、以下のようなデバッグ用途でdataの値をprintしたい場合、デバッグ箇所の文脈によってdataの型が変わるため、dataの型を限定することは現実的ではありません。そのためAnyを使用して、任意の型を受け入れられるようにします。

from typing import Any

def log_debug_info(label: str, data: Any) -> None:
    print(f"DEBUG [{label}]: {data}")

log_debug_info("UserID", 101)          # int も OK!
log_debug_info("Status", "Running")    # str も OK!
log_debug_info("Payload", {"id": 1})   # dict も OK!

Anyを使用すると、型チェッカーが実質的に何もしなくなりバグの温床になります。さらに、エディタが変数の型を推測できないため、入力補完が機能しなくなります。

Anyは実用上の柔軟性のために用意されているものであり、型が何になるか考えるのが面倒だからAnyを使うようなことはしないようにしましょう。

⑤ 「関数そのもの」を渡す (Callable)

デコレータのように、関数を引数にして関数を返す関数を定義するときに、関数自体を引数や返り値にしたいときには、Callableを使います。たとえば

def scale_size(size: int, ratio: float) -> float:

であれば

Callable[[int, float], float]

のようにCallable[[引数], 返り値]として定義します。

引数が不定の場合は 「…」を使って Callable[…, 返り値] と書くこともできます。以下の例のようにデコレータを実装する場合にCallableが必要になります。

import time
import functools
from typing import Callable, Any

def timer_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
    """実行時間を計測してログ出力するデコレータ"""
    
    @functools.wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        start_time = time.perf_counter()
        
        # 本来の関数を実行
        result = func(*args, **kwargs)
        
        end_time = time.perf_counter()
        print(f"DEBUG: {func.__name__} の実行時間: {end_time - start_time:.4f}秒")
        return result
        
    return wrapper


@timer_decorator
def heavy_process(n: int) -> list[int]:
    return [i**2 for i in range(n)]

ret = heavy_process(200_000_000)    # DEBUG: heavy_process の実行時間: 4.1305秒

⑥ 「再代入」を禁止する (Final)

Pythonの仕様上は定数を書き換えることができてしまいますが、Final を付けておくことで、誤った代入コードを書いた瞬間にエディタが警告を出してくれます。

「APIの接続先URLのように、起動時に一度決めたら、プログラムの実行中に書き換わっては困る値」を守ることができます。

from typing import Final

API_BASE_URL: Final[str] = "https://api.example.com/v1"
API_BASE_URL = "http://localhost:8000"  # NG!

5. 一歩進んだ型定義 – より柔軟で堅牢なコードへ

① 型エイリアス :型に別名をつけてわかりやすくする

型に別名をつけることで、型ヒントの価値を高めることができます。例えば、以下の例の様にbase64でエンコードしたバイナリデータを返すとき単にstrと返すよりstrにBase64Strといった別名をつけることで、単にstrとするより明示的に何が返ってくるかを宣言することができます。

import base64

type Base64Str = str

def encode_image_file(file_path: str) -> Base64Str:
    """画像ファイルを読み込み、Base64形式の文字列に変換して返す"""
    with open(file_path, "rb") as image_file:
        binary_data = image_file.read()      
  
        # バイナリをBase64に変換し、文字列(str)としてデコード
        encoded_bytes = base64.b64encode(binary_data)

        # 戻り値は Base64Str (実体は str)
        return encoded_bytes.decode()

ジェネリクス :様々な型に対応できる関数やクラスを作る

「どんな型のデータでも受け入れたい。でも、中身が何であるかという情報は絶対に失いたくない」という場合にジェネリクスを使用します。Python3.12以降では関数名やクラス名の直後に型変数を定義できます。例えば、classのとき、以下の様に定義することで、valueの型に応じた型チェックが実行されます。

class Data[T]:
    def __init__(self, value: T):
        self.value: T = value

int_data = Data(100)
int_data.value + 50      # OK! 
int_data.value + "text"  # NG!  int型にstr型を足せないことを検知

str_data = Data("text")
str_data.value + 50      # NG!  str型にint型を足せないことを検知
str_data.value + "text"  # OK!

ジェネリックを使うことでAnyを回避することができ、上述のCallableのサンプルのデコレータの例からAnyを排除して定義することができます。

import time
import functools
from typing import Callable

def timer_decorator[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    """実行時間を計測してログ出力するデコレータ"""

    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        
        start_time = time.perf_counter()        
        result = func(*args, **kwargs)
        end_time = time.perf_counter()
        print(f"DEBUG: {func.__name__} の実行時間: {end_time - start_time:.4f}秒")
        
        return result
        
    return wrapper

@timer_decorator
def heavy_process(n: int) -> list[int]:
    """重いデータ処理をシミュレーション"""
    return [i**2 for i in range(n)]

result = heavy_process(100000)     # エディタがresultの型をlist[int]と認識する!

6. 実践:型チェッカーmypyでエラーを見つけてみよう

Pythonは型ヒントを「ただの注釈」として扱い、間違っていてもそのまま実行してしまうため、これまで紹介してきた型ヒントは、書くだけでは不十分です。

型チェッカーを使って、実行前に「型の矛盾」を見つける必要があります。型チェッカーにはMyPyPyrightPyreflyと種類がありますが、今回は王道のMyPyの使い方を簡単に紹介します。

インストールは簡単で、

uv add --dev mypy

で終了です。型チェックは

uv run mypy main.py

のように実行できます。例えば、

class Data[T]:
    def __init__(self, value: T):
        self.value: T = value

int_data = Data(100)
int_data.value + 50      # OK! 
int_data.value + "text"  # NG!

str_data = Data("text")
str_data.value + 50      # NG! 
str_data.value + "text"  # OK!

だと

main.py:7: error: Unsupported operand types for + ("int" and "str")  [operator]
main.py:10: error: Unsupported operand types for + ("str" and "int")  [operator]

のように、エラーが検出されます。CLIから都度検出したりpre-commitでコミット前に検出したりするよりも、エディタの拡張機能を使用して、実装中にリアルタイムで型チェックして、実装中に型の矛盾を原因としたバグを検出・除去することをおすすめします。

7. おわりに

型ヒントの付け方と型チェッカーの使い方がなんとなくご理解いただければ充分です!型ヒントは慣れるまで面倒ですが、少しずつでも良いので、型ヒントを書き始めてみてください。それだけで、コードの品質と開発スピードは劇的に向上します。

もっと深く知りたい方は、公式の Python Typing ドキュメント や、mypy ドキュメント を覗いてみてください。


採用情報
お問い合わせ