Type Hints
Type hints คืออะไร
หัวข้อที่มีชื่อว่า “Type hints คืออะไร”Type hint คือ annotation ที่แนบไปกับตัวแปร, พารามิเตอร์ หรือค่าที่คืนออกมา เพื่อประกาศว่าควรเป็นประเภทใด
Python เก็บ annotation เหล่านี้ไว้ใน __annotations__ แต่ไม่เคยนำไปใช้ตอน runtime — เป็นเพียง metadata สำหรับ static analysis tool และผู้อ่านโค้ด
x: int = 10name: str = "Alice": int และ : str ไม่มีผลต่อการรันโค้ดเลย มีไว้เพื่อสื่อสาร intent เท่านั้น
Annotate signature ของฟังก์ชัน
หัวข้อที่มีชื่อว่า “Annotate signature ของฟังก์ชัน”สถานที่ที่มีประโยชน์ที่สุดสำหรับ type hints คือ function signature — ช่วยบอก contract ของทุก callable
def add(a: int, b: int) -> int: return a + b
def repeat(text: str, times: int = 1) -> str: return text * times-> int annotate ประเภทที่คืน ส่วน default value อยู่หลัง type annotation: times: int = 1
Built-in generic types
หัวข้อที่มีชื่อว่า “Built-in generic types”Python 3.9+ ให้ใช้ collection type โดยตรงเป็น generic ได้เลย ไม่ต้อง import
scores: list[int] = [95, 87, 92]mapping: dict[str, int] = {"a": 1, "b": 2}pair: tuple[str, int] = ("Alice", 30)unique: set[str] = {"red", "green", "blue"}ก่อน Python 3.9 ต้องเขียน List[int], Dict[str, int] จาก typing แนะนำให้ใช้ lowercase built-in form ใน Python 3.9+
Optional — ค่าที่อาจเป็น None
หัวข้อที่มีชื่อว่า “Optional — ค่าที่อาจเป็น None”Optional[T] หมายความว่าค่าเป็น T หรือ None ก็ได้ ย่อมาจาก Union[T, None]
from typing import Optional
def find_user(user_id: int) -> Optional[str]: users = {1: "Alice", 2: "Bob"} return users.get(user_id) # returns str or Noneใน Python 3.10+ เขียนเป็น str | None ได้เลย:
def find_user(user_id: int) -> str | None: users = {1: "Alice", 2: "Bob"} return users.get(user_id)ทั้งสองรูปแบบเทียบเท่ากัน แนะนำ X | None ใน Python สมัยใหม่
Optional narrowing — if x: ไม่ใช่การเช็ค None
หัวข้อที่มีชื่อว่า “Optional narrowing — if x: ไม่ใช่การเช็ค None”Optional[int] (หรือ int | None) แปลว่า “เป็น int หรือ None อย่างใดอย่างหนึ่ง” การเช็คด้วย if x: narrow ค่า
None ออกไปก็จริง แต่มัน narrow ค่า falsy อื่น ๆ ที่ type นี้อนุญาตออกไปด้วย: 0, 0.0, "", [] โค้ดที่ AI เขียนมักเขียน
if x: ทั้งที่ต้องการ if x is not None: และสองแบบนี้ต่างกันแบบเงียบ ๆ ทันทีที่ 0 เป็นค่าที่ถูกต้อง:
def describe(count: int | None) -> str: if count: # BUG: also treats 0 as falsy return f"count={count}" return "no count"
describe(0) # "no count" -- wrong, 0 is a valid countdescribe(None) # "no count" -- correct, by accidentทางแก้คือเช็ค identity กับ None ตรง ๆ: if count is not None: ทั้ง mypy และ pyright ไม่จับ bug นี้เลย —
if count: ถูกต้องตาม type สำหรับ int | None ทุกประการ ดังนั้น bug นี้จึงมองไม่เห็นสำหรับ checker ทั้งสองตัว
เห็นได้เฉพาะเป็นคำตอบที่ผิดตอน runtime เท่านั้น
Union — หนึ่งในหลายประเภท
หัวข้อที่มีชื่อว่า “Union — หนึ่งในหลายประเภท”Union[X, Y] หมายถึงค่าอาจเป็น X หรือ Y ก็ได้ ใน Python 3.10+ ใช้ operator | ได้โดยตรง
from typing import Union
def process(value: Union[int, str]) -> str: return str(value)
# Python 3.10+ shorthand:def process_modern(value: int | str) -> str: return str(value)Any ลบการตรวจสอบทิ้ง
หัวข้อที่มีชื่อว่า “Any ลบการตรวจสอบทิ้ง”Any เข้ากันได้กับทุก type ทั้งสองทิศทาง: parameter ที่ type เป็น Any รับอะไรก็ได้ และ value ที่ type เป็น Any
สามารถ assign ให้อะไรก็ได้ นี่คือสิ่งที่ทำให้มันอันตราย — มันไม่ได้แปลว่า “ไม่รู้ type แต่สอดคล้องกัน” แต่แปลว่า
checker หยุดตรวจสอบ ตรงจุดนั้นในโปรแกรมไปเลย
from typing import Any
def add_any(a: Any, b: Any) -> int: return a + b # checker trusts the -> int annotation; nothing verifies it
add_any("ab", "cd") # returns 'abcd', a str, not an int -- no checker complainsAny คือวิธีที่พบบ่อยที่สุดที่โค้ด AI เขียน “ดูเหมือน” ถูก type ครบทุกที่ทั้งที่ไม่บังคับใช้อะไรเลย มองหามันในโค้ดที่ AI สร้าง
ด้วยการ grep หา Any, # type: ignore และ **kwargs ที่ไม่ annotate — แต่ละอย่างคือรูโหว่ที่ checker มองทะลุไม่ได้
ควรใช้ type จริง, object (ซึ่งบังคับให้ narrow ก่อนใช้งาน) หรือ Protocol แทนการหยิบ Any มาใช้เพราะสะดวก
Invariance — list[Dog] ไม่ใช่ list[Animal]
หัวข้อที่มีชื่อว่า “Invariance — list[Dog] ไม่ใช่ list[Animal]”generic container ใน Python เป็น invariant โดย default: list[Dog] ไม่ใช่ subtype ของ list[Animal]
ถึงแม้ Dog จะเป็น subtype ของ Animal ก็ตาม เรื่องนี้ทำให้เกือบทุกคนที่มาจากภาษาที่มี covariant array แปลกใจ
class Animal: def speak(self) -> str: ...
class Dog(Animal): def speak(self) -> str: return "Woof"
def make_all_speak_bad(animals: list[Animal]) -> list[str]: return [a.speak() for a in animals]
dogs: list[Dog] = [Dog(), Dog()]make_all_speak_bad(dogs) # list[Dog] is not list[Animal] -- list is invariantchecker ทั้งสองปฏิเสธการเรียกนี้ mypy --strict:
error: Argument 1 to "make_all_speak_bad" has incompatible type "list[Dog]"; expected "list[Animal]" [arg-type]note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variancenote: Consider using "Sequence" instead, which is covariantpyright:
error: Argument of type "list[Dog]" cannot be assigned to parameter "animals" of type "list[Animal]" in function "make_all_speak_bad" "list[Dog]" is not assignable to "list[Animal]" Type parameter "_T@list" is invariant, but "Dog" is not the same as "Animal" Consider switching from "list" to "Sequence" which is covariant (reportArgumentType)เหตุผลคือเรื่อง mutation: ถ้า list[Dog] ถูกยอมรับในที่ที่คาดหวัง list[Animal] function นั้นก็สามารถ
append Cat() เข้าไปในสิ่งที่ caller เชื่อว่าเป็น list[Dog] ได้ ทางแก้ตามที่ทั้งสอง message บอกไว้คือรับ type ที่
covariant และอ่านอย่างเดียวแทน — Sequence[Animal] (หรือ Iterable[Animal]) เมื่อ function อ่านจาก collection
เพียงอย่างเดียว:
from collections.abc import Sequence
def make_all_speak(animals: Sequence[Animal]) -> list[str]: return [a.speak() for a in animals]
make_all_speak(dogs) # fine -- Sequence is covariant, and never mutatedนี่คือกฎ invariance เดียวกับที่อยู่เบื้องหลังตัวอย่าง TypeIs ด้านล่างของบทนี้
Type aliases
หัวข้อที่มีชื่อว่า “Type aliases”Type alias ตั้งชื่อให้กับ annotation ที่ซับซ้อน ทำให้ signature อ่านง่ายขึ้น
Vector = list[float]UserId = intUserMap = dict[UserId, str]
def scale(v: Vector, factor: float) -> Vector: return [x * factor for x in v]Python 3.12+ มี type statement:
type Vector = list[float] # Python 3.12+Type parameter โดยไม่ต้องใช้ TypeVar (Python 3.12+)
หัวข้อที่มีชื่อว่า “Type parameter โดยไม่ต้องใช้ TypeVar (Python 3.12+)”PEP 695 ให้ syntax สำหรับ generics โดยเฉพาะ: ประกาศ type parameter ในวงเล็บเหลี่ยมต่อท้ายชื่อได้เลย
ไม่ต้อง import TypeVar ไม่ต้องมีตัวแปรระดับ module
def first[T](items: list[T]) -> T: return items[0]
class Box[T]: def __init__(self, value: T) -> None: self.value = value
type Pair[T] = tuple[T, T] # generic aliasการ bound type parameter
หัวข้อที่มีชื่อว่า “การ bound type parameter”type parameter สามารถถูกจำกัดให้เป็นชุด type ที่แน่นอนด้วย T: (A, B, C) หรือ bound ให้เป็น supertype ด้วย
T: Base เฉพาะ operation ที่ใช้ได้กับทุก type ใน bound เท่านั้นที่ทำได้ภายใน function:
def smaller[T: (int, float, str)](a: T, b: T) -> T: return a if a < b else b # < is valid on int, float, and strการเรียก smaller(3, "x") จะถูกปฏิเสธ — T ต้อง resolve เป็น type เดียวจากในชุด constraint ต่อการเรียกหนึ่งครั้ง
การผสม type จึงเป็น type error ไม่ใช่ runtime error
รูปแบบเดิมยังใช้ได้ และเป็นสิ่งที่คุณจะเห็นใน code ที่ target 3.11 หรือเก่ากว่า:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T: return items[0]Protocol vs ABC — structural กับ nominal typing
หัวข้อที่มีชื่อว่า “Protocol vs ABC — structural กับ nominal typing”ทั้งคู่ให้ function รับ “อะไรก็ได้ที่มีรูปร่างแบบ X” แต่ตรวจสอบความเข้ากันได้คนละแบบ
Protocol(structural / duck typing ตรวจแบบ static): class จะ satisfyProtocolได้แค่มี method ที่ถูกต้องเท่านั้น — ไม่ต้อง inheritABC(nominal บังคับตอน runtime): class ต้อง inherit จาก abstract base อย่างชัดเจน และ Python เองจะปฏิเสธ การสร้าง instance ของ subclass ที่ยังไม่ได้ implement@abstractmethodครบทุกตัว
from abc import ABC, abstractmethodfrom typing import Protocol
class Greeter(Protocol): def greet(self) -> str: ...
class FormalGreeter: # no inheritance from Greeter needed def greet(self) -> str: return "Good day."
def welcome(g: Greeter) -> str: return g.greet()
welcome(FormalGreeter()) # accepted -- structural match is enough
class Shape(ABC): @abstractmethod def area(self) -> float: ...
Shape() # TypeError: Can't instantiate abstract class Shape without an implementation for abstract method 'area'ใช้ Protocol เมื่อไม่ได้เป็นเจ้าของ class ที่จะถูกส่งเข้ามา (object ของ third-party, test double) ใช้ ABC เมื่อ
เป็นเจ้าของ hierarchy ทั้งหมดและต้องการให้ Python เอง — ไม่ใช่แค่ checker — ปฏิเสธ subclass ที่ยังไม่สมบูรณ์
TypedDict — dictionary ที่มี type
หัวข้อที่มีชื่อว่า “TypedDict — dictionary ที่มี type”dict[str, object] ธรรมดาไม่บอก checker เลยว่ามี key อะไรบ้างหรือแต่ละ key เก็บอะไร TypedDict แก้ปัญหานี้:
มันอธิบายรูปร่างของ dict ทีละ key ทั้งหมดในระดับ type เท่านั้น — ตอน runtime มันคือ dict ธรรมดา ไม่มีการ validate ใด ๆ
from typing import NotRequired, Required, TypedDict
class Movie(TypedDict, total=False): title: Required[str] # always required, even though total=False year: NotRequired[int] # optional even though total=True is not set
m1: Movie = {"title": "Arrival"}m2: Movie = {"title": "Dune", "year": 2021}total=True (ค่า default) หมายความว่าทุก key จำเป็นเว้นแต่ถูกครอบด้วย NotRequired[...] total=False กลับด้าน
default นี้ ทำให้ทุก key เป็น optional เว้นแต่ถูกครอบด้วย Required[...] การผสมสองแบบ — อย่างด้านบน — ทำให้
TypedDict หนึ่งตัวสื่อได้ว่า “ต้องมี title เสมอ ส่วน year เป็น optional”
เพราะ TypedDict สร้าง dict ธรรมดาออกมา ไม่มีอะไรห้ามการสร้างมันเองด้วยมือโดยขาด key หรือสะกดชื่อ key ผิดตอน
runtime — มีแค่ checker เท่านั้นที่จับได้ และจับได้เฉพาะตอนที่ literal ถูก assign ตรงเข้า target ที่ annotate เป็น Movie
Literal, Final และ ClassVar
หัวข้อที่มีชื่อว่า “Literal, Final และ ClassVar”Literal[...]จำกัด type ให้เหลือค่าที่ระบุไว้แทนที่จะเป็นทั้ง type —Literal["read", "write"]รับแค่สอง string นั้น ไม่ใช่strทั่วไปFinalทำเครื่องหมายว่าชื่อนี้จะไม่ถูก assign ซ้ำหลังผูกครั้งแรก checker ปฏิเสธการ assign ครั้งที่สอง Python เองไม่บังคับใช้ClassVar[T]ทำเครื่องหมายว่า attribute เป็นของ class ไม่ใช่ของแต่ละ instance — บอกว่าค่าถูกแชร์ และเตือนถ้า พยายาม set ผ่านself
from typing import ClassVar, Final, Literal
MAX_RETRIES: Final = 3Mode = Literal["read", "write"]
class Counter: total: ClassVar[int] = 0 def __init__(self) -> None: Counter.total += 1 # mutating through the class name, not self
def open_mode(mode: Mode) -> str: return f"opened in {mode} mode"@overload — หนึ่ง signature ต่อรูปแบบการเรียก
หัวข้อที่มีชื่อว่า “@overload — หนึ่ง signature ต่อรูปแบบการเรียก”@overload ให้ function เดียวประกาศ type signature ได้หลายแบบสำหรับรูปแบบ argument ที่ต่างกัน มีแค่ definition
สุดท้ายที่ไม่มี decorator เท่านั้นที่มี implementation จริง ส่วน stub ที่ decorate ด้วย @overload มีไว้สำหรับ checker
เท่านั้น
from typing import overload
@overloaddef first(items: list[int]) -> int: ...@overloaddef first(items: list[str]) -> str: ...def first(items: list[int] | list[str]) -> int | str: return items[0]
first([1, 2, 3]) # checker infers -> intfirst(["a", "b"]) # checker infers -> strถ้าไม่มี @overload signature เดียว list[int] | list[str] -> int | str จะบังคับให้ caller ทุกที่ narrow
return value เอง ทั้งที่ input บอก output type ชัดเจนอยู่แล้ว
TypeGuard vs TypeIs — narrowing เทียบข้างกัน
หัวข้อที่มีชื่อว่า “TypeGuard vs TypeIs — narrowing เทียบข้างกัน”ทั้ง TypeGuard[T] และ TypeIs[T] ทำเครื่องหมาย function ว่าเป็น runtime type check ที่ checker ควรเชื่อ
ความต่างอยู่ที่สิ่งที่เกิดขึ้นใน branch else:
def is_int_guard(val: object) -> TypeGuard[int]: return isinstance(val, int)
def is_int_is(val: int | str) -> TypeIs[int]: return isinstance(val, int)เมื่อตรวจด้วย reveal_type ใน if/else บน val: int | str ทั้ง mypy --strict และ pyright เห็นตรงกันในความต่างนี้:
| Function | branch if | branch else |
|---|---|---|
TypeGuard[int] | int | int | str — ไม่ถูก narrow |
TypeIs[int] | int | str — narrow ให้ตัด int ออก |
TypeGuard รับประกันแค่ branch positive เท่านั้น checker ไม่สามารถสมมติได้ว่าอะไรถูกตัดออกเมื่อเช็คไม่ผ่าน
(การเช็คอาจเป็นกฎกำหนดเองที่ไม่ใช่ subtype test จริง) TypeIs เข้มงวดกว่าเรื่องสิ่งที่มันอนุญาตให้ตรวจ — มันต้องการให้
narrowed type เป็น subtype ของ input type — และแลกกับสิ่งนั้น checker จะ narrow ทั้งสอง branch ให้ ใช้ TypeIs
เมื่อการเช็คนั้นเป็น “นี่คือสมาชิกของ subtype หรือไม่” จริง ๆ และเก็บ TypeGuard ไว้สำหรับ narrowing ที่ไม่สามารถแสดง
เป็น subtype test ธรรมดาได้
cast — คำโกหกที่ checker เชื่อ
หัวข้อที่มีชื่อว่า “cast — คำโกหกที่ checker เชื่อ”cast(T, value) บอก checker ว่า “เชื่อฉันเถอะ นี่คือ T” และไม่มีการตรวจตอน runtime เลย ต่างจาก isinstance
มันไม่เปลี่ยนอะไรเกี่ยวกับ object นั้นเลย — มันเปลี่ยนแค่สิ่งที่ checker เชื่อเกี่ยวกับมันนับจากจุดนั้นไป
from typing import cast
def get_config_value(data: dict[str, object], key: str) -> str: return cast(str, data[key]) # cast performs NO runtime check
config: dict[str, object] = {"port": 8080} # port is actually an intport = get_config_value(config, "port")port.upper() # AttributeError: 'int' object has no attribute 'upper' -- cast liedchecker เห็น port: str และไม่ complain อะไรเกี่ยวกับ .upper() เลย crash จะเกิดขึ้นตอน runtime เท่านั้น ห่างไกล
จากจุดที่โกหกเกิดขึ้นมาก ใช้ cast เฉพาะเมื่อมีข้อมูลที่ checker ไม่สามารถอนุมานได้จริง ๆ (และมั่นใจว่าถูกต้อง) —
ไม่ใช่เป็นทางลัดผ่าน type error จริง
Never และ assert_never — พิสูจน์ exhaustiveness
หัวข้อที่มีชื่อว่า “Never และ assert_never — พิสูจน์ exhaustiveness”Never (สะกดว่า NoReturn ในโค้ดเก่า) คือ type ของ function ที่ไม่มีวัน return แบบปกติ — มันจะ raise หรือวนลูป
ตลอดไปเสมอ assert_never(x) คือ pattern มาตรฐานสำหรับพิสูจน์ทั้งในระดับ checker และ runtime ว่า branch หนึ่ง
ไม่มีทาง reach ได้:
from typing import Literal, Never, assert_never
def handle_mode(mode: Literal["read", "write"]) -> str: match mode: case "read": return "reading" case "write": return "writing" case _: assert_never(mode) # checker: unreachable if Literal is exhaustive
def unreachable() -> Never: raise RuntimeError("this function never returns normally")ถ้ามีการเพิ่ม literal ตัวที่สาม (เช่น "append") ให้ Mode alias โดยไม่อัปเดต handle_mode checker จะ flag การเรียก
assert_never(mode) ว่า reachable ได้ด้วย argument ที่ไม่ใช่ Never — เป็นการตรวจ exhaustiveness ตอน compile-time
สำหรับ match/if chain ตอน runtime assert_never ก็ไม่ใช่ no-op เช่นกัน: ถ้า caller ข้าม checker ไป (ด้วย cast,
จากโค้ดที่ไม่มี type, หรือจาก external input) แล้วมีค่าที่ไม่คาดคิดจริง ๆ มาถึงมัน assert_never จะ raise
AssertionError เป็นกรณีหายากที่ typing tool เป็น runtime guard จริงด้วย
Self หมายถึง “class ที่ method นี้ถูกเรียกจริง ๆ” รวมถึง subclass ด้วย — ต่างจากการสะกดชื่อ class ตรง ๆ ซึ่ง
หมายถึง class นั้นเป๊ะ ๆ เสมอ:
from typing import Self
class Builder: def __init__(self) -> None: self.parts: list[str] = [] def add(self, part: str) -> Self: self.parts.append(part) return self
Builder().add("a").add("b").parts # ['a', 'b']ใช้ Self กับ method แบบ chaining/fluent และ alternate constructor (@classmethod) เพื่อให้เรียกบน subclass
แล้วยังคง type ของ subclass ไว้ แทนที่จะแคบกลับไปเป็น base class
Callable และ ParamSpec
หัวข้อที่มีชื่อว่า “Callable และ ParamSpec”Callable[[ArgTypes], ReturnType] ให้ type กับ function value แต่ไม่พอสำหรับ decorator ที่ต้อง forward
signature ที่ ไม่รู้ล่วงหน้า ให้เหมือนเดิม — ParamSpec จับ “parameter อะไรก็ตามที่ function ที่ห่ออยู่มี”
ทำให้ return type ของ decorator สะท้อน input ของมันตรงเป๊ะ:
from collections.abc import Callablefrom typing import ParamSpec, TypeVar
P = ParamSpec("P")R = TypeVar("R")
def logged(fn: Callable[P, R]) -> Callable[P, R]: def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: print(f"calling {fn.__name__}") return fn(*args, **kwargs) return wrapper
@loggeddef add(a: int, b: int) -> int: return a + b
add(2, 3) # the checker still knows add takes (int, int) -> int through the decoratorถ้าไม่มี ParamSpec logged จะต้อง type wrapper เป็น Callable[..., R] และทุกจุดที่เรียกจะเสียการตรวจสอบ
type ของ argument บน function ที่ decorate ไปเลย
Annotation ถูก evaluate แบบ lazy (Python 3.14)
หัวข้อที่มีชื่อว่า “Annotation ถูก evaluate แบบ lazy (Python 3.14)”ก่อน 3.14 annotation ถูก evaluate ทันทีที่ statement def หรือ class ทำงาน ชื่อที่ยังไม่มีตัวตนจึงต้องใส่ quote
และ from __future__ import annotations คือทางออกที่ใช้กันทั่วไป PEP 649 เปลี่ยนเรื่องนี้: annotation ถูกเก็บเป็น
function ที่จะทำงานเมื่อมีคนขอเท่านั้น forward reference จึงใช้ได้เลย
class Node: def link(self, other: Node) -> Node: # Node is not defined yet — still fine self.other = other return selfเวลาต้องอ่าน annotation ตอน runtime ให้ใช้ annotationlib แทนการแตะ __annotations__ ตรง ๆ:
from annotationlib import Format, get_annotations
get_annotations(Node.link, format=Format.VALUE) # resolved objectsget_annotations(Node.link, format=Format.STRING) # the source textget_annotations(Node.link, format=Format.FORWARDREF) # ForwardRef where it cannot resolveFormat.VALUE คือตัวที่กัดได้: มันบังคับให้ทุก annotation resolve เป็น object จริง ดังนั้น annotation ที่อ้างชื่อที่
ไม่มีอยู่จริงที่ไหนเลยจะ raise NameError — แต่จะเกิดเฉพาะตอนที่มีคนขอ Format.VALUE เท่านั้น ซึ่งอาจนานหลังจาก
function ถูกนิยามไปแล้ว:
def broken(x: DoesNotExist) -> None: ... # defining this is completely fine under PEP 649
get_annotations(broken, format=Format.STRING) # {'x': 'DoesNotExist', 'return': 'None'} -- no errorget_annotations(broken, format=Format.VALUE) # NameError: name 'DoesNotExist' is not definedstatic checker ไม่ได้ lazy แบบนี้ให้ฟรี ๆ: ทั้ง mypy และ pyright resolve ทุกชื่อที่ annotate ไว้แบบ eager
ระหว่างการวิเคราะห์ ดังนั้นทั้งสองจะ flag DoesNotExist ทันทีที่บรรทัด def — นานก่อนที่การเรียก Format.VALUE
ใด ๆ จะ raise ตอน runtime ดูการเทียบ checker ด้านล่าง
TYPE_CHECKING — import ที่มีไว้สำหรับ checker เท่านั้น
หัวข้อที่มีชื่อว่า “TYPE_CHECKING — import ที่มีไว้สำหรับ checker เท่านั้น”typing.TYPE_CHECKING เป็น False ตอน runtime และ type checker ทุกตัวถือว่าเป็น True การครอบ import ด้วยมัน
ทำให้ reference type ใน annotation ได้โดยไม่ต้องเสีย cost การ import จริง (หรือเสี่ยง circular import) — ตราบใดที่
annotation เป็น string หรือ PEP 649 defer มันนานพอที่ไม่มีอะไรพยายาม resolve แบบ eager
from typing import TYPE_CHECKING
if TYPE_CHECKING: from collections.abc import Iterable # never imported at runtime
def total_of(values: "Iterable[int]") -> int: return sum(values)
total_of([1, 2, 3]) # 6 -- works fine, Iterable was never needed at runtime"Iterable" in globals() # Falseเครื่องมือ typing สมัยใหม่ที่เหลือ
หัวข้อที่มีชื่อว่า “เครื่องมือ typing สมัยใหม่ที่เหลือ”| ฟีเจอร์ | เวอร์ชัน | ทำอะไร |
|---|---|---|
@override | 3.12 | checker ตรวจว่า method นั้น override method ของ base จริง |
TypeVar("T", default=int) | 3.13 | ค่า default ของ type parameter ที่ไม่ได้ระบุ |
typing.ReadOnly | 3.13 | ทำเครื่องหมายว่า key ของ TypedDict เป็น read-only |
typing.TypeIs | 3.13 | predicate สำหรับ narrowing ที่ narrow ฝั่ง negative ด้วย ต่างจาก TypeGuard |
@warnings.deprecated | 3.13 | เครื่องหมาย deprecation ตัวเดียวที่ทั้ง checker และ runtime เข้าใจ |
from collections.abc import Sequencefrom typing import ReadOnly, TypeIs, TypedDict, override
class User(TypedDict): id: ReadOnly[int] name: str
def is_str_list(values: Sequence[object]) -> TypeIs[Sequence[str]]: return all(isinstance(v, str) for v in values)
class Base: def run(self) -> None: ...
class Child(Base): @override def run(self) -> None: ...พารามิเตอร์ต้องเป็น Sequence[object] ไม่ใช่ list[object]: list เป็น invariant ทำให้ list[str] ไม่ใช่ subtype ของ list[object] และ return type ของ TypeIs ต้องเป็น subtype ของ type พารามิเตอร์เสมอ ถ้าใช้ list[object] จะได้ error จริงดังนี้ — mypy --strict: Narrowed type "list[str]" is not a subtype of input type "list[object]" [narrowed-type-not-subtype]; pyright: Return type of TypeIs ("list[str]") is not consistent with value parameter type ("list[object]") (reportGeneralTypeIssues) ส่วน Sequence เป็น covariant ทำให้ narrow จาก Sequence[object] ไป Sequence[str] ถูกต้อง
runtime validation ด้วย library อย่าง pydantic ไม่ใช่ตัวแทนของ toolkit ทั้งหมดนี้ และในทางกลับกันก็เช่นกัน: static type (บทนี้) ถูกตรวจก่อนโปรแกรมรันและไม่มีต้นทุนตอน runtime เลย ส่วน pydantic validate ข้อมูลจริง (JSON จาก network request, config file) ตอน runtime และ raise เมื่อ input ไม่ถูกต้อง ใช้ static type ทุกที่ แล้วเพิ่ม runtime validation เฉพาะจุดที่ข้อมูลที่ไม่น่าเชื่อถือเข้ามาในโปรแกรม
Demo แบบ runnable เต็มรูปแบบ
หัวข้อที่มีชื่อว่า “Demo แบบ runnable เต็มรูปแบบ”from typing import Optional, Union
# --- variable annotations ---x: int = 10name: str = "Alice"scores: list[int] = [95, 87, 92]mapping: dict[str, int] = {"a": 1, "b": 2}
# --- function signatures ---def add(a: int, b: int) -> int: return a + b
def repeat(text: str, times: int = 1) -> str: return text * times
# --- Optional / X | None ---def find_user(user_id: int) -> Optional[str]: users = {1: "Alice", 2: "Bob"} return users.get(user_id)
# --- Union / | ---def process(value: Union[int, str]) -> str: return str(value)
# --- type alias ---Vector = list[float]
def scale(v: Vector, factor: float) -> Vector: return [x * factor for x in v]
# --- run it ---print(add(3, 4))print(repeat("Hi", 3))print(find_user(1))print(find_user(99))print(process(42))print(process("hello"))print(scale([1.0, 2.0, 3.0], 2.5))
# hints do NOT restrict runtime behaviourprint(add("oops", " still works")) # Python does not enforce at runtimeLoading Python runtime (first run only)…
Depth demo: ทุกอย่างข้างบน รันจริง
หัวข้อที่มีชื่อว่า “Depth demo: ทุกอย่างข้างบน รันจริง”from collections.abc import Callable, Sequencefrom typing import ( Any, ClassVar, Final, Literal, Never, ParamSpec, Self, TYPE_CHECKING, Protocol, TypeGuard, TypeIs, TypeVar, assert_never, cast, overload,)from annotationlib import Format, get_annotations
if TYPE_CHECKING: from collections.abc import Iterable
# --- Any erases checking ---def add_any(a: Any, b: Any) -> int: return a + b # mypy --strict flags this (no-any-return); pyright does not -- see below
print("=== Any ===")result = add_any("ab", "cd") # both checkers accept the call itselfprint(f" add_any('ab', 'cd') = {result!r} (type {type(result).__name__}, not int)")
# --- invariance: list[Dog] is not list[Animal] ---class Animal: def speak(self) -> str: return "..."
class Dog(Animal): def speak(self) -> str: return "Woof"
def make_all_speak(animals: Sequence[Animal]) -> list[str]: return [a.speak() for a in animals]
print("=== invariance ===")dogs: list[Dog] = [Dog(), Dog()]print(f" make_all_speak(dogs) = {make_all_speak(dogs)}") # fine at runtime AND with Sequence
# --- Optional narrowing: "if x:" is not a None check ---def describe(count: int | None) -> str: if count: # BUG: also treats 0 as falsy -- not the same as "count is not None" return f"count={count}" return "no count"
print("=== Optional narrowing ===")print(f" describe(5) -> {describe(5)!r}")print(f" describe(0) -> {describe(0)!r}") # WRONG: 0 is a valid countprint(f" describe(None) -> {describe(None)!r}")
# --- TypedDict: total, Required/NotRequired ---from typing import NotRequired, Required, TypedDict
class Movie(TypedDict, total=False): title: Required[str] year: NotRequired[int]
m1: Movie = {"title": "Arrival"}m2: Movie = {"title": "Dune", "year": 2021}print("=== TypedDict ===")print(f" m1 = {m1}")print(f" m2 = {m2}")
# --- Literal / Final / ClassVar ---MAX_RETRIES: Final = 3Mode = Literal["read", "write"]
class Counter: total: ClassVar[int] = 0 def __init__(self) -> None: Counter.total += 1
def open_mode(mode: Mode) -> str: return f"opened in {mode} mode"
print("=== Literal / Final / ClassVar ===")Counter(); Counter()print(f" MAX_RETRIES={MAX_RETRIES} Counter.total={Counter.total}")print(f" {open_mode('read')}")
# --- @overload ---@overloaddef first(items: list[int]) -> int: ...@overloaddef first(items: list[str]) -> str: ...def first(items: list[int] | list[str]) -> int | str: return items[0]
print("=== overload ===")print(f" first([1, 2, 3]) = {first([1, 2, 3])}")print(f" first(['a', 'b']) = {first(['a', 'b'])}")
# --- TypeGuard vs TypeIs ---def is_str_seq_guard(vals: Sequence[object]) -> TypeGuard[Sequence[str]]: return all(isinstance(v, str) for v in vals)
def is_str_seq_is(vals: Sequence[object]) -> TypeIs[Sequence[str]]: return all(isinstance(v, str) for v in vals)
print("=== TypeGuard vs TypeIs (runtime identical; checker differs -- see below) ===")mixed: Sequence[object] = ["a", "b"]print(f" is_str_seq_guard(mixed) = {is_str_seq_guard(mixed)}")print(f" is_str_seq_is(mixed) = {is_str_seq_is(mixed)}")
# --- cast is a lie the checker believes ---def get_config_value(data: dict[str, object], key: str) -> str: return cast(str, data[key]) # cast performs NO runtime check
config: dict[str, object] = {"port": 8080} # port is actually an intprint("=== cast ===")port = get_config_value(config, "port")print(f" get_config_value(config, 'port') = {port!r}, actual type {type(port).__name__}")try: port.upper() # str method on what is really an int -- cast liedexcept AttributeError as e: print(f" port.upper() raised: {e}")
# --- Never / assert_never exhaustiveness ---def handle_mode(mode: Literal["read", "write"]) -> str: match mode: case "read": return "reading" case "write": return "writing" case _: assert_never(mode) # checker: unreachable if Literal is exhaustive
def unreachable() -> Never: raise RuntimeError("this function never returns normally")
print("=== Never / assert_never ===")print(f" handle_mode('read') = {handle_mode('read')!r}")bad_mode = cast(Literal["read", "write"], "delete") # bypass the checker on purposetry: handle_mode(bad_mode)except AssertionError as e: print(f" assert_never caught a real bad runtime value: {e}")
# --- Self ---class Builder: def __init__(self) -> None: self.parts: list[str] = [] def add(self, part: str) -> Self: self.parts.append(part) return self
print("=== Self ===")print(f" Builder().add('a').add('b').parts = {Builder().add('a').add('b').parts}")
# --- Callable / ParamSpec ---P = ParamSpec("P")R = TypeVar("R")
def logged(fn: Callable[P, R]) -> Callable[P, R]: def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: print(f" calling {fn.__name__}{args}") return fn(*args, **kwargs) return wrapper
@loggeddef add(a: int, b: int) -> int: return a + b
print("=== Callable / ParamSpec ===")print(f" add(2, 3) = {add(2, 3)}")
# --- PEP 649: annotations are lazy; Format.VALUE forces evaluation ---def broken(x: DoesNotExist) -> None: ... # deliberately undefined -- both checkers flag this eagerly
print("=== PEP 649 / annotationlib ===")print(f" Format.STRING -> {get_annotations(broken, format=Format.STRING)}")try: get_annotations(broken, format=Format.VALUE)except NameError as e: print(f" Format.VALUE raised: {e}")
# --- Protocol: structural typing, no inheritance needed ---class Greeter(Protocol): def greet(self) -> str: ...
class FormalGreeter: def greet(self) -> str: return "Good day."
def welcome(g: Greeter) -> str: return g.greet()
print("=== Protocol (structural) ===")print(f" welcome(FormalGreeter()) = {welcome(FormalGreeter())!r}")
# --- ABC: nominal typing, enforced at runtime ---from abc import ABC, abstractmethod
class Shape(ABC): @abstractmethod def area(self) -> float: ...
class Circle(Shape): def __init__(self, r: float) -> None: self.r = r def area(self) -> float: return 3.14159 * self.r ** 2
print("=== ABC (nominal, runtime-enforced) ===")print(f" Circle(2).area() = {Circle(2).area()}")try: Shape() # type: ignore[abstract] # ABC blocks instantiation of an unimplemented abstractmethodexcept TypeError as e: print(f" Shape() raised: {e}")
# --- PEP 695 bound: constrain T to a fixed set of types ---def smaller[T: (int, float, str)](a: T, b: T) -> T: return a if a < b else b
print("=== PEP 695 bound ===")print(f" smaller(3, 5) = {smaller(3, 5)}")print(f" smaller('pear', 'apple') = {smaller('pear', 'apple')!r}")
# --- TYPE_CHECKING: the import above never runs at runtime ---def total_of(values: "Iterable[int]") -> int: return sum(values)
print("=== TYPE_CHECKING ===")print(f" total_of([1, 2, 3]) = {total_of([1, 2, 3])}")print(f" 'Iterable' in globals() at runtime: {'Iterable' in globals()}")Loading Python runtime (first run only)…
ตรวจ demo ของบทนี้: mypy กับ pyright
หัวข้อที่มีชื่อว่า “ตรวจ demo ของบทนี้: mypy กับ pyright”การรัน depth demo ด้านบนผ่านทั้งสอง checker (mypy 2.3.1, pyright 1.1.414) เจอ error จริงที่ตั้งใจเก็บไว้สองจุด และจุดที่สองตัวเห็นต่างกันหนึ่งจุด:
$ uvx mypy --strict type_hints_depth.pytype_hints_depth.py: error: Returning Any from function declared to return "int" [no-any-return]type_hints_depth.py: error: Name "DoesNotExist" is not defined [name-defined]Found 2 errors in 1 file (checked 1 source file)
$ uvx pyright type_hints_depth.pytype_hints_depth.py - error: "DoesNotExist" is not defined (reportUndefinedVariable)1 error, 0 warnings, 0 informations- ตรงกัน ที่
DoesNotExist: checker ทั้งสอง resolve ชื่อใน annotation แบบ eager ระหว่างการวิเคราะห์ ดังนั้นชื่อที่ ไม่มีตัวตนใน demo ของ PEP 649 ถูก flag โดยทั้งคู่ ทั้งที่ runtime จะ raise ก็ต่อเมื่อFormat.VALUEบังคับมันเท่านั้น — ตัวอย่างที่ชัดเจนของ “timeline ของ checker ไม่ใช่ timeline ของ runtime” - เห็นต่าง ที่
add_any: mypy--strictมีเช็คเฉพาะชื่อno-any-returnที่ flag การ return ค่า typeAnyจาก function ที่ประกาศว่าคืนอะไรที่เฉพาะเจาะจงกว่า — ถึงแม้Anyจะเกี่ยวข้องอยู่ก็ตาม การตั้งค่า default ของ pyright ไม่มีเช็คแบบนี้เปิดอยู่ จึงไม่รายงานอะไรสำหรับบรรทัดเดียวกัน นี่คือช่องว่างความเข้มงวดจริงระหว่าง default ของ checker ทั้งสอง ไม่ใช่ bug ของฝั่งไหน - การเรียก
Shape()ที่เป็น abstract instantiation ถูกกดไว้ด้วย# type: ignore[abstract]ใน demo — ถ้าไม่มีมัน mypy จะรายงานerror: Cannot instantiate abstract class "Shape" with abstract attribute "area" [abstract]และ pyright จะรายงานerror: Cannot instantiate abstract class "Shape" ... "Shape.area" is not implemented (reportAbstractUsage)— checker ทั้งสองเห็นตรงกันตรงนี้เช่นกัน แค่สะกด message ต่างกัน
ตัวอย่าง invariance และ TypeGuard/TypeIs ก่อนหน้านี้ในบทถูกตรวจด้วยวิธีเดียวกัน message ที่ตรงเป๊ะถูก quote
ไว้ตรงจุดที่มันปรากฏ
flowchart LR
subgraph CT["Check time (mypy / pyright)"]
A["source with annotations"] --> B{"types consistent?"}
B -->|no| C["error, nothing runs"]
end
subgraph RT["Runtime (CPython)"]
D["same source, annotations ignored"] --> E["runs regardless of type errors"]
end
B -->|yes| D
C -.->|"code can still run
if you ignore the error"| D Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”แยก timeline สองอันไว้ให้ชัด: check time (mypy/pyright อ่าน source แล้วปฏิเสธ type ที่ไม่สอดคล้องกัน โดยไม่รัน
โค้ดสักบรรทัดเดียว) กับ runtime (CPython รัน source จริงและไม่เคยดู annotation เลย เว้นแต่จะเรียก
annotationlib.get_annotations(..., format=Format.VALUE) หรือคล้ายกันตรง ๆ) ไม่มีอะไรเชื่อมสองอย่างนี้อัตโนมัติ —
error จาก checker ไม่ได้หยุดไม่ให้ python file.py รัน และ checker ที่ผ่านสะอาดก็ไม่ได้รับประกันพฤติกรรมตอน
runtime ที่ถูกต้อง (ตามที่ if count:, Any และ cast แสดงให้เห็นข้างบน) Type hints คือ pass ที่สอง ที่เป็น
ทางเลือก บนโค้ดของคุณ ที่คุณเลือกจะรันและเลือกจะเชื่อฟังเอง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| Type hints (mypy/pyright) | จับ bug หลายประเภทได้ก่อนรันโค้ด | ไม่มีการป้องกันตอน runtime เลย — ต้องรัน checker จริงใน CI |
Any | ทางออกสำหรับโค้ดที่ dynamic จริง ๆ หรือกำลัง migrate | ปิดการตรวจสอบแบบเงียบ ๆ ทุกที่ที่มันปรากฏ |
cast | สื่อข้อมูลที่ checker อนุมานเองไม่ได้ | ไม่มีการตรวจตอน runtime — cast ที่ผิดจะพังไกลจากจุดต้นเหตุมาก |
TypedDict / Protocol | มีโครงสร้างโดยไม่ต้องมี runtime class หรือต้นทุน validation | ไม่มีอะไรห้าม dict/object ที่สร้างเองด้วยมือละเมิดรูปร่างนั้น |
| Runtime validation (pydantic, เช็คเอง) | ปฏิเสธข้อมูลที่ผิดจริง ๆ ตรงจุดที่รับเข้ามา | มีต้นทุนตอน runtime จริง ยังต้องใช้ static type กับส่วนที่เหลือทั้งหมดอยู่ดี |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “โค้ดฉัน type hints ครบทุกที่ ปลอดภัยแน่นอน” — Type hints แข็งแรงเท่าที่ checker ที่รันจริงกับมันเท่านั้น และ
Any/cast/# type: ignoreสามารถทำลายทุกอย่างแบบเงียบ ๆ ได้ grep หาสามอย่างนี้ก่อนเชื่อว่าโค้ด “typed ครบ” - “
Optional[int]แปลว่าif x:คือการเช็ค None” — มัน narrowNoneออกก็จริง แต่ narrow0ออกด้วย — ดูหัวข้อ ด้านบน ใช้if x is not None:เมื่อ zero (หรือ"",[]) เป็นค่าที่ถูกต้อง - “
list[Dog]ควรใช้ได้ทุกที่ที่list[Animal]ใช้ได้ — Dog ก็คือ Animal” — ใช้ได้แค่ตอนอ่านอย่างเดียวlistเป็น invariant เพราะมัน mutable ใช้Sequence/Iterableสำหรับ parameter ที่อ่านอย่างเดียว - “mypy กับ pyright เห็นตรงกันเสมอ เพราะ implement ‘ระบบ type’ เดียวกัน” — ทั้งคู่ implement typing spec
ตาม PEP เดียวกันจริง แต่ default ความเข้มงวดและเช็คพิเศษต่างกัน —
no-any-returnข้างบนคือตัวอย่างจริง รันทั้งคู่ เมื่อ claim เกี่ยวกับ “checker” มีความสำคัญ - “Type hints ทำให้โปรแกรมช้าลง” — ไม่มีต้นทุนตอน runtime เลย — annotation เป็นแค่ metadata (ถูก defer ตาม PEP 649 ไม่เคยถูก evaluate เว้นแต่มีคนขอ) ต้นทุนทั้งหมดอยู่ตอน check time ใน CI
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลารีวิว type hints ของ function ที่ AI generate ให้ตรวจสิ่งเหล่านี้ก่อนเชื่อมัน:
- parameter ถูก type เป็น
Anyหรือมี# type: ignoreวางอยู่บนบรรทัดที่ “แค่อยากให้มันทำงาน” หรือเปล่า? ทั้งสอง แปลว่า checker ไม่ได้ตรวจอะไรตรงนั้นเลย - function รับ
list[Base]แต่จุดที่เรียกส่งlist[Subclass]เข้ามาหรือเปล่า? ถ้า function อ่านจาก parameter อย่างเดียว มันควร type เป็นSequence[Base]ถ้ามัน type เป็นlist[Base]แล้วยังรับ argument แบบlist[Subclass]ได้อยู่ แปลว่าไม่เคยมีการรัน checker กับโค้ดนี้จริง x: Optional[int](หรือint | None) ถูกเช็คด้วยif x:แทนที่จะเป็นif x is not None:หรือเปล่า? มองหา type ไหนก็ตามที่0,""หรือ[]เป็นค่าที่ถูกต้อง- มี
cast(...)ปรากฏที่ไหนบ้าง? ทุกcastคือ claim ที่ไม่มีการตรวจสอบ — เช็คว่า claim นั้นเป็นจริงหรือเปล่า - type hints มีอยู่แต่ function ไม่เคยถูกรันผ่าน
mypyหรือpyrightใน CI เลยหรือเปล่า? type ที่เป็นของประดับ โดยไม่มีการบังคับใช้ให้ความมั่นใจที่ผิด ๆ
🐛 หา bug ในโค้ดนี้
def shipping_fee(order_total: float, free_shipping_threshold: float | None) -> float: if free_shipping_threshold: return 0.0 if order_total >= free_shipping_threshold else 5.0 return 5.0
print(shipping_fee(10.0, 0)) # threshold=0 should mean "always free shipping"print(shipping_fee(10.0, None)) # no threshold configured -- always chargeเฉลย
free_shipping_threshold=0 ตั้งใจให้หมายถึง “ฟรีเสมอ” (order total เท่าไหร่ก็ >= 0) แต่ if free_shipping_threshold:
ถือว่า 0 เป็น falsy แล้วตกไปที่ branch เดียวกับ None — คิดเงิน 5.0 แทนที่จะเป็น 0.0 ทั้ง mypy --strict และ
pyright ยอมรับโค้ดนี้โดยไม่มี error เลยสักตัว: float | None คือ type ที่ if x: ได้รับอนุญาตให้เช็คพอดี ทางแก้คือ
if free_shipping_threshold is not None: