Build CoreOrdered learning track

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.

12 min read2297 words
PrevNext
Lesson 1335 lesson track0719 Build Core
#python#typing#type-hints#mypy+4 more

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:

  1. Memahami gradual typing.
  2. Menulis annotation function dan variable.
  3. Memahami None, Optional, dan union.
  4. Memakai collection generics.
  5. Memakai Literal untuk finite values.
  6. Memahami TypedDict.
  7. Memahami Protocol.
  8. Memahami Generic dan TypeVar secara praktis.
  9. Memakai type alias dan NewType.
  10. Menyusun typing strategy untuk project Python.
  11. 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 where list[BaseClass] is expected because list is mutable.
  • Read-only abstractions like Sequence are more flexible than mutable list.

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 Any at external boundary;
  • parse/validate into typed domain object quickly;
  • do not let Any leak 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:

  1. Type all domain models.
  2. Type all service functions.
  3. Type storage boundary return values.
  4. Type CLI parsing helpers.
  5. Avoid Any except JSON boundary.
  6. Add type checker to quality command.
  7. Fix issues while codebase small.

For legacy codebase:

  1. Start with new/changed files.
  2. Type public APIs first.
  3. Add config with gradual strictness.
  4. Ban new untyped function definitions.
  5. Reduce Any leakage over time.
  6. Use tests to protect behavior.
  7. 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:

  1. All domain models typed.
  2. All public functions typed.
  3. All service functions typed.
  4. All repository protocols typed.
  5. CLI main() returns int.
  6. Parsing helpers typed.
  7. Tests typed with -> None.
  8. Any allowed only at external data boundary.
  9. Use CaseId value object instead of raw string for domain identity.
  10. Use TypedDict for JSON representation if keeping manual serialization.
  11. Use Protocol for repository/clock if service class introduced.
  12. Avoid type: ignore unless 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:

  1. What bug does this prevent?
  2. What mapping changes are needed for JSON?
  3. What changes are needed in CLI parsing?
  4. Does this add runtime validation?
  5. Is NewType enough 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:

  1. Does fake need to inherit Protocol?
  2. What happens if fake misses save_all?
  3. Does Protocol enforce runtime behavior?
  4. How does this improve tests?
  5. 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:

  1. Does Python runtime enforce type hints by default?
  2. What is gradual typing?
  3. Why should function return types be annotated?
  4. When do variable annotations help?
  5. What is Case | None?
  6. Why must optional values be checked?
  7. When should you use Iterable instead of list?
  8. What is TypedDict?
  9. Does TypedDict validate JSON at runtime?
  10. What is Protocol?
  11. How is Protocol different from ABC?
  12. What is NewType?
  13. What is the difference between type alias and NewType?
  14. What does Any do?
  15. Why is object safer than Any?
  16. What is cast?
  17. Why is cast not validation?
  18. What is a generic function?
  19. What is a typing anti-pattern?
  20. 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:

  1. Add type hints to domain models.
  2. Add type hints to service functions.
  3. Explain runtime vs static typing.
  4. Use Case | None correctly.
  5. Use collection generics.
  6. Use Iterable, Sequence, or Mapping appropriately.
  7. Define a type alias.
  8. Define a NewType.
  9. Define a TypedDict.
  10. Define a Protocol.
  11. Type a fake repository.
  12. Explain Any leakage.
  13. Avoid cast as validation.
  14. Run mypy or pyright.
  15. 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.
Lesson Recap

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.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.