所有入門單元免費開放 · 課程內容持續更新中

型別提示:寫給人和 AI 看的說明

看到 def summarize(records),你知道 records 長什麼樣子嗎?型別提示用幾個字寫清楚「這個函式收什麼、回傳什麼」,讓人看得懂、編輯器幫得上忙,也讓 AI 改程式時有規矩可循。

第 3.4 堂約 12 分鐘

一個看不出來的問題

上一堂我們讓記帳工具學會處理錯誤。現在,請你假裝是第一次打開這個專案的人,看到這一行:

def summarize(records):

records 是什麼?一個串列?裡面放字典?字典有哪些鍵?金額是文字還是數字?回傳的又是什麼?你得往下讀完整個函式,甚至去翻 storage.py,才猜得出來。

AI 也一樣。Claude Code 要改這個函式時,得先花力氣「猜」資料的形狀,猜錯了,就會寫出看起來對、其實會壞掉的程式。

型別提示(type hints)就是用來解決這件事的:在函式的「門口」直接寫清楚,進來的是什麼、出去的是什麼。

基本寫法

def parse_amount(text: str) -> int:
    ...
  • 參數後面加 : 型別,代表這個參數應該收什麼,這裡是 str(文字)
  • 括號後面加 -> 和型別,代表這個函式會回傳什麼,這裡是 int(整數)

函式簽名的拆解圖:def parse_amount,參數 text 標註為 str,代表收文字;箭頭後的 int 代表回傳整數;下方說明這一行就是函式的使用說明

不用讀函式內容,光看第一行就知道怎麼用它。

變數也可以加,通常只有在「一開始是空的、看不出來會裝什麼」時才需要:

by_category: dict[str, int] = {}

意思是:這是一個字典,鍵是文字(類別名稱),值是整數(金額)。

常用型別一覽

寫法 意思 例子
str、int、float、bool 文字、整數、小數、真假 "餐費"、85、1.05、True
list[str] 裝著文字的串列 ["餐費", "交通"]
dict[str, int] 鍵是文字、值是整數的字典 {"餐費": 145}
None 什麼都沒有;只印東西不回傳的函式就寫這個 def show_report(...) -> None

還有一種很常用、但放不進表格的寫法:Record | None,中間那條直線讀作「或」,意思是「可能是一筆紀錄,也可能什麼都沒有」。等一下就會用到。

替「一筆紀錄」取個名字

記帳工具裡最重要的資料,是「一筆花費」這個字典。它在 storage.py、report.py、main.py 裡到處傳來傳去。與其每個地方都寫 dict,不如替它取個正式的名字。

在 tracker 裡新增 models.py:

# tracker/models.py:專案裡的資料長什麼樣子
from typing import TypedDict


class Record(TypedDict):
    """一筆花費紀錄。"""

    date: str      # 例如 "2026-11-07"
    category: str  # 例如 "餐費"
    item: str      # 例如 "雞腿便當"
    amount: int    # 正整數,單位:元

TypedDict 的意思是「有固定欄位的字典」。class 這個字你還不用深究,這裡把它當成「定義一種資料格式」就好。用起來,它就是一般的字典,什麼都不用改。

從此以後,「一筆紀錄」在整個專案裡只有一種說法,就是 Record;「所有紀錄」就是 list[Record]。

幫專案加上型別提示

把每個函式的門口都標清楚。report.py 變成這樣:

# tracker/report.py
import pandas as pd

from tracker.models import Record


def summarize(records: list[Record]) -> dict[str, int]:
    """依類別加總,回傳像 {"餐費": 145, "交通": 30} 的字典。"""
    by_category: dict[str, int] = {}
    for r in records:
        cat = r["category"]
        by_category[cat] = by_category.get(cat, 0) + r["amount"]
    return by_category


def show_report(records: list[Record]) -> None:
    if not records:
        print("還沒有任何紀錄")
        return
    totals = summarize(records)
    for cat, subtotal in totals.items():
        print(f"{cat}:{subtotal} 元")
    print(f"總計:{sum(totals.values())} 元")


def export_excel(records: list[Record], path: str = "expenses.xlsx") -> None:
    pd.DataFrame(records).to_excel(path, index=False)
    print(f"已匯出到 {path}")

storage.py 的讀檔函式,改成明確地組出一筆 Record:

# tracker/storage.py(只列出有改的部分)
from tracker.models import Record


def load_records() -> list[Record]:
    if not FILE.exists():
        return []
    records: list[Record] = []
    skipped = 0
    with FILE.open(encoding="utf-8-sig", newline="") as f:
        for line_no, row in enumerate(csv.DictReader(f), start=2):
            try:
                amount = parse_amount(row["amount"] or "")
            except ValueError:
                print(f"第 {line_no} 列的金額「{row['amount']}」看不懂,先跳過")
                skipped += 1
                continue
            records.append(Record(date=row["date"], category=row["category"],
                                  item=row["item"], amount=amount))
    if skipped:
        shutil.copy(FILE, BACKUP)
        print(f"原始檔已備份到 {BACKUP},請照著備份把那幾列修好")
    return records


def save_records(records: list[Record]) -> None:
    ...  # 內容不變

為什麼要改成 Record(...)?因為從 CSV 讀出來的每一列,金額都是文字;我們轉成整數後,組成一筆真正符合格式的紀錄。這樣型別提示說的,和實際的資料才會一致。

main.py 也一樣:ask_amount(question: str) -> int、add_record(records: list[Record]) -> None、main() -> None。tracker/amount.py 則是開頭那個 parse_amount(text: str) -> int。

「可能沒有」的時候:型別 | None

在 report.py 加一個新函式,找出最大的一筆花費:

def biggest_expense(records: list[Record]) -> Record | None:
    """回傳金額最大的一筆;沒有任何紀錄時回傳 None。"""
    if not records:
        return None
    biggest = records[0]
    for r in records:
        if r["amount"] > biggest["amount"]:
            biggest = r
    return biggest

回傳型別寫 Record | None,等於在門口貼了一張紙條:「我有可能什麼都不給你。」呼叫的人看到就知道要先檢查:

top = biggest_expense(records)
if top is not None:
    print(f"最大的一筆:{top['item']} {top['amount']} 元")

很多程式的 bug,都出在「以為一定有值,結果是 None」。把這件事寫進型別,就不容易忘。

讓工具幫你檢查

型別提示寫好之後,誰來檢查?兩個幫手:

1. 編輯器。 在 VS Code 裡把滑鼠移到函式名稱上,就會看到它的參數和回傳型別;打 r[ 時,它也會提示 date、category 這些欄位。

2. 型別檢查工具。 這裡用 mypy,它會讀遍整個專案,找出「傳錯東西」的地方。因為只有開發時才需要它,我們用 --dev 把它加成開發用套件:

uv add --dev mypy
uv run mypy .

最後那個點代表「檢查目前這個資料夾」。假設你在選單裡不小心少打了一對括號,寫成 show_report(load_records),mypy 會說類似這樣的話:

main.py:35: error: Argument 1 to "show_report" has incompatible type
"Callable[[], list[Record]]"; expected "list[Record]"  [arg-type]
Found 1 error in 1 file (checked 6 source files)

翻成白話:「show_report 要的是一串紀錄,你卻給了一個函式(還沒呼叫的 load_records)。」這種錯誤,如果沒有工具檢查,要等你執行、按下「看報表」那一刻才會發現。

比較圖:沒有型別檢查時,錯誤要等使用者操作到那一步才當掉;有型別檢查時,寫完程式執行 mypy,就在執行前抓到錯誤並修好

你可能會卡在這裡

  • mypy 說 pandas 缺少 stubs(訊息裡有 import-untyped 或 Library stubs not installed):pandas 的型別資訊是另外一個套件,執行 uv add --dev pandas-stubs 就好。
  • 一次跑出好多錯誤:很正常,先從第一個開始看。很多時候修好一個,後面好幾個就一起消失了。
  • 不知道該寫什麼型別:把滑鼠移到變數上,看編輯器猜的是什麼,通常就是答案。
請 Claude 幫你加型別提示

請幫下面這個 Python 檔案的每個函式加上型別提示。專案裡已經有 tracker/models.py 定義了 Record(TypedDict,欄位是 date、category、item、amount,amount 是 int)。

規則:

  1. 一筆紀錄用 Record,多筆用 list[Record]。
  2. 可能回傳 None 的函式,要寫成「型別 | None」。
  3. 不要改變任何功能,只加型別提示。
  4. 改完後,列出你不確定型別的地方,說明你為什麼這樣選。

(貼上檔案內容)

行動練習
  1. 新增 tracker/models.py,定義 Record。
  2. 幫 amount.py、storage.py、report.py、main.py 的每個函式加上型別提示,包括回傳 None 的函式。
  3. 執行 uv add --dev mypy,再執行 uv run mypy .,把錯誤修到一個都不剩。
  4. 故意寫錯一次:把 show_report(records) 改成 show_report(load_records),看 mypy 怎麼說,再改回來。
  5. 在報表選單加上「最大的一筆花費」,用 biggest_expense,並且處理「還沒有紀錄」的情況。

讀完了嗎?

標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。

卡住了?讓 AI 當你的家教複製這段提問範本,貼到 Claude 或其他 AI 工具,再補上你卡住的地方。

留言與討論

卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。

留言區即將開放。現在可以先到 GitHub 討論區 發問,或看看 交流頁 的其他管道。