Type checker,中文是型別檢查器。型別在程式裡描述的是值的定義,以及可以對它做哪些操作:例如:int 可以做整數運算,dict 可以用 .get() 取值,None 則表示沒有值,當然也就沒有 .get() 可以呼叫。
型別檢查器會讀取程式碼,根據型別標註、運算與控制流程,檢查值的使用方式是否與型別相容,不必先執行程式。
例如型別為 int 的值,就沒辦法使用 .get()。
看一個例子:假設設定資料可能是一個字典,也可能是 None。
def get_timeout(config: dict[str, int] | None) -> int:
return config.get("timeout", 30)
dict[str, int] 表示字典的 key 是字串、value 是整數;| None 表示也可能沒有設定資料;-> int 則宣告回傳值應是整數。
上面的程式拿到符合標註的字典時可以正常運作,拿到 None 就會出錯。Pyright 不必等到那次執行,就能從標註發現 config 可能沒有 .get(),並以 reportOptionalMemberAccess 回報問題。
矛盾就在這裡:參數允許 None,函式裡卻把它當成一定是字典。 我們可以把 type checker 想成一台「型別上的矛盾偵測器」,檢查宣告、推論出的型別與實際用法是否相容。
要補充的是,這些標註提供的是可供工具檢查的介面要求,但 Python 不會自動依照標註驗證傳入值。來自檔案、網路或使用者輸入的資料,仍需要在執行時驗證。Python 官方文件也明確區分了型別標註與執行期的行為。
沈默的程式碼,可能只是還沒走到出錯的那條路。
沒有編譯錯誤、執行時沒有崩潰,甚至沒有留下任何日誌(logs)——這些沈默,可能代表程式運作正常,也可能是觸發錯誤的條件尚未出現,或程式早已算錯,卻沒有任何檢查與紀錄。沒有錯誤訊號,不代表沒有問題。
型別檢查能讓其中一類問題提早出聲:不必等到那台機器上真的少了設定檔,就先指出型別與用法不相容,把錯誤提前到開發時處理。
那工具又怎麼知道你修好了?也處理 config 為 None 的情況即可:
def get_timeout(config: dict[str, int] | None) -> int:
if config is None:
return 30
return config.get("timeout", 30)
這樣進入函式時 config 有兩種可能:字典或 None。None 的分支已經提早回傳,因此能走到最後一行的,只剩字典。工具根據控制流程縮小型別範圍,這叫型別縮窄(type narrowing)——它不需要知道這次實際傳入什麼,就能判斷最後一行的用法是否安全。
把系列到目前為止的三個工具排在一起,差別會比較清楚:
| 工具 | 拿什麼跟什麼比 | 需要執行程式嗎 |
|---|---|---|
| formatter | 程式碼 vs 排版規則 | 否 |
| linter | 程式碼 vs lint 規則 | 否 |
| type checker | 值的型別 vs 使用方式 | 否 |
前兩列右邊那份標準是從外面帶進來的:一個由工具決定,一個是前人踩過坑之後寫下來的。type checker 比對的則是你自己寫下的型別,與你自己寫下的用法——這也讓它成為這條線上第一個要先寫點東西、工具才動得了的。
Python、JavaScript 這類動態型別語言,可以額外加入靜態型別檢查;C、C++、Go、Rust 這類靜態型別語言,則已經把這項工作整合進編譯流程。靜態型別語言也有 type checker,只是它常常是編譯器的一部分。
| 語言 | 代表工具 | 使用例子 |
|---|---|---|
| Python | Pyright | pyright:檢查專案 |
| JavaScript | TypeScript 編譯器 tsc |
tsc --allowJs --checkJs --noEmit index.js:檢查 JS 檔案 |
| TypeScript | tsc |
tsc --noEmit:檢查型別,不輸出 JS |
| C | Clang 編譯器 | clang -fsyntax-only main.c:檢查語法與型別,不產生執行檔 |
| C++ | Clang 編譯器 | clang++ -fsyntax-only main.cpp:檢查語法與型別,不產生執行檔 |
| Go | Go 編譯器 | go build ./...:建置時包含型別檢查 |
| Rust | rustc,透過 Cargo 呼叫 |
cargo check:檢查專案,不產生最終執行檔 |
同一個動作,差別在誰做、以及可不可以不做。動態型別語言通常可以選擇不裝;靜態型別語言沒有這個選項,過不了就編不出來。
選好工具之後,還要決定檢查到什麼程度。以 Python 為例,沒有標註時,Pyright 仍能從 count = 3 推論出整數型別;但當標註與推論都無法提供足夠資訊,就會出現檢查盲點。Pyright 以 Unknown 表示這類未知的型別。
所以,工具沒有報錯,也可能只是資訊不足。 能不能要求開發者補足資訊?這正是嚴格模式處理的一部分問題。
Pyright 提供四種模式。以下摘錄官方設定表的預設差異;「—」代表該項不回報,「錯誤」代表以 error 回報。
| 檢查項目(規則) | off | basic | standard | strict |
|---|---|---|---|---|
傳入參數型別不符(reportArgumentType) |
— | 錯誤 | 錯誤 | 錯誤 |
對可能為 None 的值取用屬性(reportOptionalMemberAccess) |
— | 錯誤 | 錯誤 | 錯誤 |
覆寫方法的型別不相容(reportIncompatibleMethodOverride) |
— | — | 錯誤 | 錯誤 |
參數缺少型別標註(reportMissingParameterType) |
— | — | — | 錯誤 |
變數型別未知(reportUnknownVariableType) |
— | — | — | 錯誤 |
off 仍可能回報未定義變數等問題,不代表完全不檢查。往右走有兩件事在同時發生:一是開啟更多型別規則(例如 reportIncompatibleMethodOverride),二是降低對「我根本不知道這是什麼型別」的容忍度——strict 會主動指出缺少標註或型別未知的地方,但它並不要求每個區域變數都手動標註。
既有專案不必一次到位。Pyright 支援依路徑啟用嚴格檢查,可以先用 basic 擋住最明顯的問題,再把核心目錄轉成 strict,之後一個目錄一個目錄往上轉:
[tool.pyright]
typeCheckingMode = "basic"
strict = ["src/core"]
Coding agent 修改程式時,可能只讀了部分檔案。假設它為了處理檔案不存在的情況,把 load_config 改成可能回傳 None,卻漏掉其他檔案裡仍直接呼叫 .get() 的呼叫端。被修改的函式本身完全合理,問題出在介面改了、呼叫端沒跟上。
這類錯誤的共同點不是程式碼長得可疑,而是兩個地方對同一個介面有不同假設。而型別檢查器能分析納入檢查範圍的檔案,指出這類跨檔案問題,不需要 agent 先把每個呼叫端都放進當下的對話裡。
型別資訊與檢查結果,分別提供兩種幫助:
於是 Day8 的檢查迴圈可以繼續延伸:
讀取型別資訊 → 修改程式 → 執行型別檢查 → 依錯誤訊息修正
但標註本身不保證 agent 會遵守,必須真的執行檢查。而「讓錯誤消失」也不一定等於修好問題:Any 允許略過許多靜態型別限制,把原本明確的型別改成 Any,或加入忽略檢查的註解,都可能只是讓工具看不見矛盾。Review 時仍要確認,它修正的是程式與介面,還是放寬了檢查。
型別檢查能檢查回傳值是否符合 int,卻無法只靠 -> int 判斷 timeout 的預設值應該是 30 還是 60。一個算錯的數字,依然可能符合型別。
下一步,需要把預期行為寫成具體案例,執行程式來核對結果。明天來談 test。