Type Hints: Static Reasoning di Dynamic Language
Part 013 — Type Hints: Static Reasoning di Dynamic Language
Membahas type hints Python untuk production engineering: gradual typing, annotations, Optional, Union, Literal, TypedDict, Protocol, Generic, NewType, type alias, mypy/pyright, dan typing strategy.
Part 013 — Type Hints: Static Reasoning di Dynamic Language
1. Tujuan Part Ini
Python adalah dynamic language.
Artinya, Python runtime tidak secara default memaksa annotation type saat function dipanggil. Kode ini valid secara runtime sampai operasi di dalam function gagal:
def greet(name: str) -> str:
return "Hello, " + name
greet(123)
Type hint name: str tidak otomatis mencegah 123 masuk pada runtime. Namun type hints sangat penting untuk production engineering karena memberi static reasoning:
- IDE autocomplete lebih baik;
- refactor lebih aman;
- contract function lebih jelas;
- boundary antar module lebih eksplisit;
- bug type bisa ditemukan sebelum runtime;
- reviewer lebih cepat memahami intent;
- codebase besar lebih maintainable;
- dependency inversion dengan Protocol lebih ringan;
- domain model lebih defensible.
Part ini membahas typing sebagai alat desain, bukan sekadar dekorasi syntax.
Target setelah part ini:
- Memahami gradual typing.
- Menulis annotation function dan variable.
- Memahami
None,Optional, dan union. - Memakai collection generics.
- Memakai
Literaluntuk finite values. - Memahami
TypedDict. - Memahami
Protocol. - Memahami
GenericdanTypeVarsecara praktis. - Memakai type alias dan
NewType. - Menyusun typing strategy untuk project Python.
- Menghubungkan typing ke
case-tracker.
2. Mental Model: Type Hints adalah Contract untuk Tools dan Humans
Type hints dibaca oleh:
- engineer;
- IDE;
- static type checker seperti mypy atau pyright;
- linter tertentu;
- documentation generator;
- framework tertentu;
- runtime introspection jika library memilih memakainya.
Tetapi Python runtime tidak enforce annotation secara default.
def add(a: int, b: int) -> int:
return a + b
Ini tetap bisa dipanggil:
add("a", "b")
Output:
ab
Karena operator + valid untuk string.
Maka type hints harus dipahami sebagai:
deklarasi intent yang diperiksa oleh tooling, bukan security boundary runtime.
Jika input datang dari user, network, file, atau database, tetap perlu runtime validation.
3. Gradual Typing
Python mendukung gradual typing.
Kamu tidak harus mengetik seluruh codebase sekaligus.
Kamu bisa mulai dari:
- function baru;
- domain model;
- public API;
- service boundary;
- repository protocol;
- configuration object;
- high-risk refactor area.
Contoh tanpa typing:
def create_case(title):
...
Langkah pertama:
def create_case(title: str) -> Case:
...
Langkah berikutnya:
def create_case(title: str, *, priority: CasePriority = CasePriority.MEDIUM) -> Case:
...
Gradual typing berarti kamu boleh punya area typed dan untyped, tetapi harus punya arah.
4. Function Annotations
Basic:
def normalize_title(title: str) -> str:
return title.strip()
Return None:
def add_note(case: Case, note: str) -> None:
case.add_note(note)
Multiple parameter:
def can_transition(from_status: CaseStatus, to_status: CaseStatus) -> bool:
return to_status in ALLOWED_TRANSITIONS[from_status]
4.1 Why Return Type Matters
Tanpa return type:
def find_case(cases: list[Case], case_id: str):
...
Caller tidak tahu apakah:
- always
Case; Case | None;- raises;
- returns dict;
- returns boolean.
Lebih jelas:
def find_case(cases: list[Case], case_id: str) -> Case | None:
...
Atau:
def get_case(cases: list[Case], case_id: str) -> Case:
...
Type hints memaksa kamu mendefinisikan semantics.
5. Variable Annotations
case_id: str = "CASE-001"
case_count: int = 10
is_closed: bool = False
Sering tidak perlu jika obvious:
case_id = "CASE-001"
Berguna jika:
- empty collection;
- optional variable;
- type tidak obvious;
- narrowing;
- public module constant.
Contoh empty collection:
cases: list[Case] = []
case_by_id: dict[CaseId, Case] = {}
Tanpa annotation, type checker mungkin tidak tahu element type.
6. Collection Types
Modern Python memakai built-in generics:
case_ids: list[str] = []
case_by_id: dict[str, Case] = {}
statuses: set[CaseStatus] = set()
workflow: tuple[CaseStatus, ...] = (...)
Tuple fixed length:
case_ref: tuple[str, str] = ("tenant-1", "CASE-001")
Tuple variable length:
notes: tuple[str, ...] = ("Created", "Submitted")
Nested:
cases_by_status: dict[CaseStatus, list[Case]] = {}
Jangan takut nested types, tetapi jika signature terlalu panjang, gunakan type alias.
7. Abstract Collection Types
Untuk parameter, concrete list kadang terlalu spesifik.
Jika function hanya butuh iterasi:
from collections.abc import Iterable
def count_closed_cases(cases: Iterable[Case]) -> int:
return sum(1 for case in cases if case.status is CaseStatus.CLOSED)
Jika butuh index/length:
from collections.abc import Sequence
def first_case(cases: Sequence[Case]) -> Case | None:
if not cases:
return None
return cases[0]
Jika hanya read mapping:
from collections.abc import Mapping
def find_case(case_by_id: Mapping[CaseId, Case], case_id: CaseId) -> Case | None:
return case_by_id.get(case_id)
Jika mutate mapping:
from collections.abc import MutableMapping
def register_case(case_by_id: MutableMapping[CaseId, Case], case: Case) -> None:
case_by_id[case.id] = case
Rule:
- return concrete type if you create a concrete object;
- accept abstract type if you only need behavior.
8. None, Optional, dan Union
Modern syntax:
def find_case(cases: list[Case], case_id: str) -> Case | None:
...
Equivalent older style:
from typing import Optional
def find_case(cases: list[Case], case_id: str) -> Optional[Case]:
...
Prefer modern Case | None jika baseline Python mendukungnya.
8.1 None Harus Ditangani
case = find_case(cases, "CASE-001")
print(case.title)
Type checker akan mengeluh karena case bisa None.
Perbaiki:
case = find_case(cases, "CASE-001")
if case is None:
raise CaseNotFoundError("CASE-001")
print(case.title)
Ini memaksa failure semantics jelas.
8.2 Avoid Optional Overuse
Buruk:
@dataclass
class Case:
id: str | None
title: str | None
status: CaseStatus | None
Jika field wajib, jangan jadikan optional hanya untuk memudahkan construction.
Gunakan factory, builder, atau separate input DTO.
9. Union
Union berarti value bisa salah satu dari beberapa type.
def parse_case_id(value: str | CaseId) -> CaseId:
if isinstance(value, CaseId):
return value
return CaseId(value)
Gunakan union secukupnya.
Union berlebihan membuat caller dan callee harus menangani banyak bentuk.
Smell:
def process(value: str | int | dict | Case | None):
...
Mungkin function terlalu banyak tanggung jawab.
10. Literal
Literal membatasi value ke literal tertentu.
from typing import Literal
SortDirection = Literal["asc", "desc"]
def sort_cases(cases: list[Case], direction: SortDirection) -> list[Case]:
reverse = direction == "desc"
return sorted(cases, key=lambda case: case.id.value, reverse=reverse)
Jika finite values punya domain meaning kuat, Enum sering lebih baik.
class SortDirection(Enum):
ASC = "asc"
DESC = "desc"
Use Literal for:
- simple config flags;
- overload-like APIs;
- string modes;
- small local choices.
Use Enum for:
- domain states;
- persisted values;
- values with behavior;
- values shared across modules.
11. Type Alias
Type alias membuat signature kompleks lebih readable.
CaseStoreData = list[dict[str, object]]
Modern syntax:
type CaseStoreData = list[dict[str, object]]
Contoh:
CaseIdText = str
StatusCounts = dict[CaseStatus, int]
CasesByStatus = dict[CaseStatus, list[Case]]
Namun type alias bukan new type. Alias hanya nama lain untuk type yang sama.
CaseIdText = str
TenantIdText = str
Type checker tetap menganggap keduanya str.
Jika ingin membedakan secara static, gunakan NewType atau value object.
12. NewType
NewType membuat type berbeda untuk static checker dengan runtime overhead minimal.
from typing import NewType
CaseIdText = NewType("CaseIdText", str)
TenantIdText = NewType("TenantIdText", str)
Use:
def get_case(tenant_id: TenantIdText, case_id: CaseIdText) -> Case:
...
Caller:
tenant_id = TenantIdText("tenant-1")
case_id = CaseIdText("CASE-001")
get_case(tenant_id, case_id)
If swapped, type checker can warn.
12.1 NewType vs Value Object
NewType:
- static distinction;
- runtime value still underlying type-ish;
- no validation behavior;
- simple and cheap.
Value object:
@dataclass(frozen=True)
class CaseId:
value: str
- runtime object;
- can validate;
- can define
__str__; - more explicit;
- more boilerplate.
For domain-critical identifiers, value object is often better. For lightweight static distinction, NewType is useful.
13. TypedDict
TypedDict gives type shape to dict.
Useful for JSON-like data at boundaries.
from typing import TypedDict
class CaseData(TypedDict):
id: str
title: str
status: str
notes: list[str]
Function:
def case_from_dict(data: CaseData) -> Case:
return Case(
id=CaseId(data["id"]),
title=data["title"],
status=CaseStatus(data["status"]),
notes=list(data["notes"]),
)
13.1 Optional Keys
from typing import NotRequired, TypedDict
class CaseData(TypedDict):
id: str
title: str
status: str
notes: NotRequired[list[str]]
Now notes may be absent.
Use:
notes=list(data.get("notes", []))
13.2 TypedDict Is Not Runtime Validation
If JSON data is missing fields, TypedDict does not automatically validate at runtime.
You still need validation/parsing.
TypedDict helps static code around dicts, not trust boundary enforcement.
14. Protocol
Protocol models behavior structurally.
from typing import Protocol
class CaseRepository(Protocol):
def list(self) -> list[Case]:
...
def save_all(self, cases: list[Case]) -> None:
...
Implementation does not need to inherit:
class JsonCaseRepository:
def list(self) -> list[Case]:
...
def save_all(self, cases: list[Case]) -> None:
...
If method signatures match, type checker accepts it.
14.1 Why Protocol Matters
Protocol enables dependency inversion without heavy inheritance.
Service:
class CaseService:
def __init__(self, repository: CaseRepository) -> None:
self._repository = repository
Test fake:
class FakeCaseRepository:
def __init__(self, cases: list[Case] | None = None) -> None:
self._cases = list(cases or [])
def list(self) -> list[Case]:
return list(self._cases)
def save_all(self, cases: list[Case]) -> None:
self._cases = list(cases)
No base class required.
15. Callable
Use Callable for function parameters.
from collections.abc import Callable
CasePredicate = Callable[[Case], bool]
def filter_cases(cases: Iterable[Case], predicate: CasePredicate) -> list[Case]:
return [case for case in cases if predicate(case)]
Function:
def is_open(case: Case) -> bool:
return case.status is not CaseStatus.CLOSED
Use:
open_cases = filter_cases(cases, is_open)
If callable has complex signature or named methods, Protocol can be clearer.
class CasePolicy(Protocol):
def __call__(self, case: Case) -> bool:
...
16. Generics
Generic types let one class/function work with multiple types while preserving type relationships.
Simple generic function:
from typing import TypeVar
T = TypeVar("T")
def first(items: Sequence[T]) -> T | None:
if not items:
return None
return items[0]
If input is Sequence[Case], return is Case | None.
If input is Sequence[str], return is str | None.
16.1 Generic Repository Example
from typing import Generic, Protocol, TypeVar
IdT = TypeVar("IdT")
EntityT = TypeVar("EntityT")
class Repository(Protocol[IdT, EntityT]):
def get(self, entity_id: IdT) -> EntityT:
...
def save(self, entity: EntityT) -> None:
...
Then:
class CaseRepository(Repository[CaseId, Case], Protocol):
...
Do not start with generics unless duplication or abstraction pressure exists.
17. Variance: Practical Awareness
Variance determines substitutability of generic types.
You do not need deep theory yet, but remember:
list[SubClass]is not always safe wherelist[BaseClass]is expected because list is mutable.- Read-only abstractions like
Sequenceare more flexible than mutablelist.
Example:
def render_cases(cases: Sequence[Case]) -> list[str]:
...
Accepts tuple/list and subtype-friendly scenarios better than:
def render_cases(cases: list[Case]) -> list[str]:
...
Practical rule:
For inputs, use the weakest abstract type that provides required behavior.
18. Any
Any disables type checking for that value.
from typing import Any
def parse(data: dict[str, Any]) -> Case:
...
Sometimes needed at trust boundaries. But Any spreads if not contained.
Bad:
def process(value: Any) -> Any:
...
Any is contagious.
Strategy:
- allow
Anyat external boundary; - parse/validate into typed domain object quickly;
- do not let
Anyleak into domain/service layers.
19. object vs Any
object means any object, but you cannot do arbitrary operations without narrowing.
def describe(value: object) -> str:
return str(value)
This is safer than Any.
With Any:
def unsafe(value: Any) -> str:
return value.not_a_real_method()
Type checker will not complain.
With object:
def safer(value: object) -> str:
return value.not_a_real_method()
Type checker complains.
Use object when you truly accept any value but only use object-safe behavior.
20. Type Narrowing
Type checker narrows after checks.
def describe_case(case: Case | None) -> str:
if case is None:
return "missing"
return case.title
After if case is None, checker knows case is Case.
With isinstance:
def parse_case_id(value: str | CaseId) -> CaseId:
if isinstance(value, CaseId):
return value
return CaseId(value)
21. cast
cast tells type checker to treat value as a type. It does not validate runtime.
from typing import cast
data = load_unknown_data()
case_data = cast(CaseData, data)
Use sparingly.
If data comes from outside, prefer runtime validation.
Bad:
case = cast(Case, unknown)
case.transition_to(...)
If unknown is not Case, runtime still fails.
cast is a type-checker hint, not a conversion.
22. Runtime Validation vs Static Typing
Static typing:
def transition_case(case: Case, target_status: CaseStatus) -> None:
...
Runtime validation:
def parse_case_status(raw_status: str) -> CaseStatus:
try:
return CaseStatus(raw_status.strip().upper())
except ValueError as error:
...
Both are needed.
Use static typing for internal correctness. Use runtime validation at boundaries.
Boundary examples:
- CLI args;
- JSON file;
- HTTP request;
- environment config;
- database row;
- third-party API.
23. mypy vs pyright
Two common type checkers:
- mypy;
- pyright.
Both are widely used. They differ in defaults, strictness, speed, and ecosystem integration.
You do not need to use both in one small project. Pick one.
23.1 pyright Configuration Example
{
"typeCheckingMode": "strict",
"include": ["src", "tests"]
}
Often stored as pyrightconfig.json.
23.2 mypy Configuration Example
In pyproject.toml:
[tool.mypy]
python_version = "3.12"
strict = true
mypy_path = "src"
Strict from day one may be too much for existing code. For learning, try strict on small project to see issues.
24. Strict Typing Strategy
For a new small project:
- Type all domain models.
- Type all service functions.
- Type storage boundary return values.
- Type CLI parsing helpers.
- Avoid
Anyexcept JSON boundary. - Add type checker to quality command.
- Fix issues while codebase small.
For legacy codebase:
- Start with new/changed files.
- Type public APIs first.
- Add config with gradual strictness.
- Ban new untyped function definitions.
- Reduce
Anyleakage over time. - Use tests to protect behavior.
- Avoid huge “type everything” rewrite.
25. Typing case-tracker Domain
from dataclasses import dataclass, field
from enum import Enum
from uuid import uuid4
class CaseStatus(Enum):
DRAFT = "DRAFT"
SUBMITTED = "SUBMITTED"
UNDER_REVIEW = "UNDER_REVIEW"
ESCALATED = "ESCALATED"
CLOSED = "CLOSED"
@dataclass(frozen=True)
class CaseId:
value: str
@dataclass(eq=False)
class Case:
id: CaseId
title: str
status: CaseStatus = CaseStatus.DRAFT
notes: list[str] = field(default_factory=list)
def transition_to(self, target_status: CaseStatus) -> None:
...
def add_note(self, note: str) -> None:
...
def create_case(title: str) -> Case:
return Case(
id=CaseId(f"CASE-{uuid4()}"),
title=title,
)
This makes domain contract explicit.
26. Typing Serialization Boundary
from typing import NotRequired, TypedDict
class CaseData(TypedDict):
id: str
title: str
status: str
notes: NotRequired[list[str]]
Functions:
def case_to_dict(case: Case) -> CaseData:
return {
"id": case.id.value,
"title": case.title,
"status": case.status.value,
"notes": list(case.notes),
}
def case_from_dict(data: CaseData) -> Case:
return Case(
id=CaseId(data["id"]),
title=data["title"],
status=CaseStatus(data["status"]),
notes=list(data.get("notes", [])),
)
But if data comes from json.loads, type checker may see Any.
raw_data = json.loads(raw_content)
You need runtime validation or careful boundary casting.
For small project, you can write validation function:
def parse_case_data(data: object) -> CaseData:
if not isinstance(data, dict):
raise ValueError("Case data must be an object")
if not isinstance(data.get("id"), str):
raise ValueError("Case id must be a string")
if not isinstance(data.get("title"), str):
raise ValueError("Case title must be a string")
if not isinstance(data.get("status"), str):
raise ValueError("Case status must be a string")
notes = data.get("notes", [])
if not isinstance(notes, list) or not all(isinstance(note, str) for note in notes):
raise ValueError("Case notes must be a list of strings")
return {
"id": data["id"],
"title": data["title"],
"status": data["status"],
"notes": notes,
}
This is verbose, but illustrates boundary validation. In real projects, libraries like Pydantic may help, but core mental model matters.
27. Typing Repository Protocol
from typing import Protocol
class CaseRepository(Protocol):
def list(self) -> list[Case]:
...
def save_all(self, cases: list[Case]) -> None:
...
Service:
class CaseService:
def __init__(self, repository: CaseRepository) -> None:
self._repository = repository
def create_new_case(self, title: str) -> Case:
cases = self._repository.list()
case = create_case(title)
cases.append(case)
self._repository.save_all(cases)
return case
Fake repository:
class FakeCaseRepository:
def __init__(self, cases: list[Case] | None = None) -> None:
self._cases = list(cases or [])
def list(self) -> list[Case]:
return list(self._cases)
def save_all(self, cases: list[Case]) -> None:
self._cases = list(cases)
Static checker verifies shape.
28. Typing Error Classes
class InvalidCaseTransitionError(Exception):
from_status: CaseStatus
to_status: CaseStatus
def __init__(self, from_status: CaseStatus, to_status: CaseStatus) -> None:
self.from_status = from_status
self.to_status = to_status
super().__init__(f"Cannot transition case from {from_status.value} to {to_status.value}")
Annotating attributes improves tooling.
29. Type-Checking Tests
Tests benefit from typing too, but do not overdo.
def test_create_case_starts_as_draft() -> None:
case = create_case("Late reporting")
assert case.status is CaseStatus.DRAFT
Fixtures can be typed:
import pytest
@pytest.fixture
def case() -> Case:
return create_case("Late reporting")
Test function:
def test_case_can_add_note(case: Case) -> None:
case.add_note("Created")
assert case.notes == ["Created"]
30. Avoid Type-Driven Overengineering
Typing is not an excuse to make code worse.
Bad:
TCaseInputContravariantOptionalMapping = TypeVar(...)
If the code is simple, keep types simple.
Typing should improve:
- clarity;
- safety;
- refactorability;
- boundary contracts.
If type annotations become harder than business logic, reconsider abstraction.
31. Common Typing Anti-Patterns
31.1 Everything is Any
def process(data: Any) -> Any:
...
31.2 Optional Everywhere
@dataclass
class Case:
id: str | None
title: str | None
31.3 Ignoring Type Errors Instead of Fixing Design
# type: ignore
type: ignore is sometimes necessary, but should be rare and justified.
31.4 Type Alias Hiding Complexity
HugeType = dict[str, list[tuple[str, dict[str, object]]]]
Maybe create domain objects.
31.5 Using cast as Validation
case_data = cast(CaseData, json.loads(raw))
This does not validate runtime.
31.6 Concrete Parameter Types Too Often
def render(cases: list[Case]) -> str:
...
If only iterating, prefer Iterable[Case].
31.7 Protocol Before Need
Do not create Protocol for everything. Create it when dependency inversion/testing/polymorphism needs it.
32. Typing Policy for case-tracker
Recommended policy:
- All domain models typed.
- All public functions typed.
- All service functions typed.
- All repository protocols typed.
- CLI
main()returnsint. - Parsing helpers typed.
- Tests typed with
-> None. Anyallowed only at external data boundary.- Use
CaseIdvalue object instead of raw string for domain identity. - Use
TypedDictfor JSON representation if keeping manual serialization. - Use
Protocolfor repository/clock if service class introduced. - Avoid
type: ignoreunless documented.
Quality command:
python -m pytest
python -m ruff check .
python -m ruff format --check .
pyright
or:
python -m mypy src tests
33. Practice: Add Basic Type Hints
Take:
def normalize_status(status):
return status.strip().upper()
Refactor:
def normalize_status(status: str) -> str:
return status.strip().upper()
Take:
def list_cases(path):
return load_cases(path)
Refactor:
from pathlib import Path
def list_cases(path: Path) -> list[Case]:
return load_cases(path)
34. Practice: Replace Raw String Case ID
Before:
def get_case(case_id: str) -> Case:
...
After:
@dataclass(frozen=True)
class CaseId:
value: str
def get_case(case_id: CaseId) -> Case:
...
Question:
- What bug does this prevent?
- What mapping changes are needed for JSON?
- What changes are needed in CLI parsing?
- Does this add runtime validation?
- Is
NewTypeenough instead?
35. Practice: Use TypedDict
Define:
class CaseData(TypedDict):
id: str
title: str
status: str
notes: NotRequired[list[str]]
Update:
def case_to_dict(case: Case) -> CaseData:
...
def case_from_dict(data: CaseData) -> Case:
...
Then test:
def test_case_can_round_trip_through_dict() -> None:
...
36. Practice: Repository Protocol
Define:
class CaseRepository(Protocol):
def list(self) -> list[Case]:
...
def save_all(self, cases: list[Case]) -> None:
...
Implement fake and service.
Questions:
- Does fake need to inherit Protocol?
- What happens if fake misses
save_all? - Does Protocol enforce runtime behavior?
- How does this improve tests?
- When would ABC be better?
37. Practice: Callable Predicate
Implement:
from collections.abc import Callable, Iterable
CasePredicate = Callable[[Case], bool]
def filter_cases(cases: Iterable[Case], predicate: CasePredicate) -> list[Case]:
return [case for case in cases if predicate(case)]
Test with:
def is_closed(case: Case) -> bool:
return case.status is CaseStatus.CLOSED
38. Practice: Type Checker Drill
Intentionally write:
def find_case(cases: list[Case], case_id: CaseId) -> Case | None:
...
case = find_case(cases, CaseId("CASE-001"))
print(case.title)
Run type checker.
Expected issue:
case might be None
Fix:
case = find_case(cases, CaseId("CASE-001"))
if case is None:
raise CaseNotFoundError(CaseId("CASE-001"))
print(case.title)
This is typing forcing failure semantics.
39. Self-Check
Answer without looking:
- Does Python runtime enforce type hints by default?
- What is gradual typing?
- Why should function return types be annotated?
- When do variable annotations help?
- What is
Case | None? - Why must optional values be checked?
- When should you use
Iterableinstead oflist? - What is
TypedDict? - Does
TypedDictvalidate JSON at runtime? - What is
Protocol? - How is Protocol different from ABC?
- What is
NewType? - What is the difference between type alias and NewType?
- What does
Anydo? - Why is
objectsafer thanAny? - What is
cast? - Why is
castnot validation? - What is a generic function?
- What is a typing anti-pattern?
- What is a sensible typing strategy for new Python code?
40. Definition of Done Part 013
You are done with this part if you can:
- Add type hints to domain models.
- Add type hints to service functions.
- Explain runtime vs static typing.
- Use
Case | Nonecorrectly. - Use collection generics.
- Use
Iterable,Sequence, orMappingappropriately. - Define a type alias.
- Define a
NewType. - Define a
TypedDict. - Define a
Protocol. - Type a fake repository.
- Explain
Anyleakage. - Avoid
castas validation. - Run mypy or pyright.
- Fix a type checker warning involving
None.
41. Ringkasan
Type hints give static reasoning in a dynamic language.
Core ideas:
- Python runtime does not enforce annotations by default;
- type hints are for humans and tools;
- gradual typing lets you adopt typing incrementally;
- function signatures are contracts;
- optional values force explicit failure handling;
- abstract collection types improve flexibility;
- TypedDict helps describe dict-shaped boundary data;
- Protocol supports structural dependency inversion;
- generics preserve relationships between input and output types;
- Any is powerful but dangerous if it leaks;
- runtime validation is still required at trust boundaries;
- type checking should improve design, not create ceremony.
Part berikutnya akan membahas testing with pytest: bagaimana test menjadi feedback loop desain, bukan sekadar verifikasi setelah kode selesai.
42. Referensi
- Python Documentation —
typing. - Python Documentation —
collections.abc. - PEP 484 — Type Hints.
- PEP 544 — Protocols.
- PEP 589 — TypedDict.
- PEP 604 — Union types as
X | Y. - mypy Documentation.
- Pyright Documentation.
You just completed lesson 13 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.