Typing & Data Classes #

Python is a dynamic language — you can put any value into any variable without type declarations. This flexibility is pleasant when prototyping, but it becomes a liability in large codebases: hidden bugs from wrong types only surface at runtime, not when you’re writing code. The typing module fills this gap with a type hints system that lets tools like mypy and modern IDEs detect type errors before the program runs. On the other hand, dataclasses simplifies creating classes whose main purpose is storing data — removing the tedious __init__, __repr__, and __eq__ boilerplate. Both aren’t optional features for production code; they’re the foundation of Python code that’s easy to understand, maintain, and test.

Why Type Hints Matter #

Before diving into the syntax, it’s important to understand what type hints actually solve. Imagine a function that accepts a user parameter — is it a name string, an integer ID, or a User object? Without type hints, you have to read the implementation or hope for up-to-date documentation. Type hints turn the code’s intent into part of the code itself.

# ANTI-PATTERN: no type information at all
def proses_pesanan(user, items, diskon):
    total = sum(item["harga"] for item in items)
    if diskon:
        total *= (1 - diskon)
    return total

# CORRECT: type hints make the function contract explicit
from typing import Optional

def proses_pesanan(
    user_id: int,
    items: list[dict[str, float]],
    diskon: Optional[float] = None
) -> float:
    total = sum(item["harga"] for item in items)
    if diskon is not None:
        total *= (1 - diskon)
    return total

Type hints don’t change program behavior at runtime — Python still doesn’t validate types automatically. What changes is tooling capability: IDEs can give accurate autocomplete, mypy can find type bugs, and the next code reader (including yourself six months from now) can immediately understand the function contract without digging into the implementation.

flowchart LR
    A[Python Code with Type Hints] --> B[mypy / pyright]
    A --> C[IDE / Editor]
    A --> D[Python Runtime]
    B --> E[Type errors detected before running]
    C --> F[Accurate autocomplete & inline docs]
    D --> G[No type validation — same performance]

Basic Types from the typing Module #

The typing module provides building blocks for describing types more complex than plain int, str, or bool. Since Python 3.9+, many of these types can be used directly from built-ins (list[int] instead of List[int]), but understanding the typing versions remains important for compatibility with older codebases.

Optional and Union #

Optional[X] is shorthand for Union[X, None] — meaning the value may be type X or None. This is one of the most commonly used type hints because nullable values are very common.

from typing import Optional, Union

# Optional[str] means the value can be str or None
def cari_user(email: str) -> Optional[dict]:
    # returns a dict if found, None if not
    ...

# Union allows several types at once
def format_nilai(nilai: Union[int, float]) -> str:
    return f"{nilai:.2f}"

# Python 3.10+: the cleaner | syntax
def format_nilai_modern(nilai: int | float) -> str:
    return f"{nilai:.2f}"
Don’t overuse Optional. If a function always returns a value (never None), don’t give it an Optional type. Overusing Optional fills code with unnecessary if x is not None checks and obscures the cases where None is actually meaningful.

List, Dict, Tuple, and Set #

For collections, you can describe the element types inside them using generic syntax:

from typing import List, Dict, Tuple, Set  # old style, Python < 3.9

# Python 3.9+: use built-ins directly
def hitung_rata(angka: list[float]) -> float:
    return sum(angka) / len(angka)

def kelompokkan_by_kategori(produk: list[dict]) -> dict[str, list[str]]:
    hasil: dict[str, list[str]] = {}
    for p in produk:
        kategori = p["kategori"]
        if kategori not in hasil:
            hasil[kategori] = []
        hasil[kategori].append(p["nama"])
    return hasil

# Tuple with a fixed length
def koordinat() -> tuple[float, float]:
    return (1.2, 3.4)

# Tuple with variable length (homogeneous)
def daftar_nilai() -> tuple[int, ...]:
    return (1, 2, 3, 4, 5)

Callable #

Callable is used to describe functions as parameters or return values:

from typing import Callable

# Callable[[type_arg1, type_arg2], type_return]
def terapkan_transformasi(
    data: list[int],
    transformasi: Callable[[int], int]
) -> list[int]:
    return [transformasi(x) for x in data]

# Usage
hasil = terapkan_transformasi([1, 2, 3], lambda x: x * 2)
# hasil: [2, 4, 6]

# A function that returns a function
def buat_multiplier(faktor: int) -> Callable[[int], int]:
    def multiplier(x: int) -> int:
        return x * faktor
    return multiplier

kali_tiga = buat_multiplier(3)
print(kali_tiga(5))  # 15

TypeVar and Generic #

Sometimes you want to create a function that works on various types but still preserves type consistency — for example, a function that accepts list[T] and returns T. This is where TypeVar and Generic come in.

TypeVar #

TypeVar defines a type variable that can be filled with any type when used, with certain constraints if needed:

from typing import TypeVar

T = TypeVar("T")

# This function accepts any list and returns its first element
# with the exact same type
def ambil_pertama(items: list[T]) -> T:
    return items[0]

# mypy knows the result is an int
angka: int = ambil_pertama([1, 2, 3])

# mypy knows the result is a str
kata: str = ambil_pertama(["halo", "dunia"])

You can also restrict a TypeVar to only certain types using bound or a list of allowed types:

from typing import TypeVar

# T must be a subclass of Comparable
Comparable = TypeVar("Comparable", int, float, str)

def nilai_maksimum(a: Comparable, b: Comparable) -> Comparable:
    return a if a > b else b

print(nilai_maksimum(10, 20))       # 20
print(nilai_maksimum(3.14, 2.71))   # 3.14
print(nilai_maksimum("apple", "banana"))  # banana

Generic Classes #

Classes can be made generic by inheriting from Generic[T]:

from typing import TypeVar, Generic, Optional

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> Optional[T]:
        if not self._items:
            return None
        return self._items.pop()

    def peek(self) -> Optional[T]:
        if not self._items:
            return None
        return self._items[-1]

    def __len__(self) -> int:
        return len(self._items)

# mypy knows this is a Stack[int]
stack_angka: Stack[int] = Stack()
stack_angka.push(1)
stack_angka.push(2)
nilai: Optional[int] = stack_angka.pop()  # type: Optional[int]
flowchart TD
    A[TypeVar T] --> B[Generic Class Stack T]
    B --> C[Stack int]
    B --> D[Stack str]
    B --> E[Stack UserModel]
    C --> F[push/pop typed int]
    D --> G[push/pop typed str]
    E --> H[push/pop typed UserModel]

Protocol — Structural Subtyping #

Protocol is a typing feature that enables type-safe duck typing. Instead of requiring a class to inherit from a specific interface (nominal typing), Protocol checks whether a class has the required methods (structural typing).

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...
    def bounding_box(self) -> tuple[float, float, float, float]: ...

class Lingkaran:
    def __init__(self, x: float, y: float, radius: float) -> None:
        self.x = x
        self.y = y
        self.radius = radius

    def draw(self) -> None:
        print(f"Drawing a circle at ({self.x}, {self.y})")

    def bounding_box(self) -> tuple[float, float, float, float]:
        return (
            self.x - self.radius,
            self.y - self.radius,
            self.x + self.radius,
            self.y + self.radius
        )

class Persegi:
    def __init__(self, x: float, y: float, sisi: float) -> None:
        self.x = x
        self.y = y
        self.sisi = sisi

    def draw(self) -> None:
        print(f"Drawing a square at ({self.x}, {self.y})")

    def bounding_box(self) -> tuple[float, float, float, float]:
        return (self.x, self.y, self.x + self.sisi, self.y + self.sisi)

# Neither class needs to explicitly inherit from Drawable
# mypy still accepts both as Drawable
def render_semua(shapes: list[Drawable]) -> None:
    for shape in shapes:
        shape.draw()

render_semua([Lingkaran(0, 0, 5), Persegi(10, 10, 20)])

Protocol is far more flexible than an abstract base class because you don’t need to modify existing classes — as long as a class has the required methods, it’s considered to satisfy the Protocol.


Advanced Type Annotations #

Literal #

Literal restricts values to certain constants, not generic types:

from typing import Literal

# Only accepts the strings "merah", "hijau", or "biru"
def set_warna(warna: Literal["merah", "hijau", "biru"]) -> None:
    print(f"Color set to: {warna}")

set_warna("merah")   # OK
set_warna("kuning")  # mypy error: Argument 1 has incompatible type

# Useful for flags or modes
Mode = Literal["baca", "tulis", "append"]

def buka_file(path: str, mode: Mode) -> None:
    with open(path, mode[0]) as f:
        ...

TypedDict #

TypedDict lets you define the structure of a dictionary with per-key types:

from typing import TypedDict, NotRequired

class Alamat(TypedDict):
    jalan: str
    kota: str
    kode_pos: str
    negara: str

class UserProfile(TypedDict):
    id: int
    nama: str
    email: str
    alamat: Alamat
    bio: NotRequired[str]  # optional field (Python 3.11+)

def format_alamat(user: UserProfile) -> str:
    alamat = user["alamat"]
    return f"{alamat['jalan']}, {alamat['kota']} {alamat['kode_pos']}"

# mypy will error if you access a key that doesn't exist
user: UserProfile = {
    "id": 1,
    "nama": "Budi",
    "email": "[email protected]",
    "alamat": {
        "jalan": "Jl. Merdeka No. 1",
        "kota": "Jakarta",
        "kode_pos": "10110",
        "negara": "Indonesia"
    }
}

Final and ClassVar #

from typing import Final, ClassVar

class Konfigurasi:
    # ClassVar: class-level attribute, not an instance
    _instance_count: ClassVar[int] = 0

    # Final: the value can't be changed after assignment
    MAX_KONEKSI: Final = 100
    NAMA_APP: Final[str] = "MyApp"

    def __init__(self) -> None:
        Konfigurasi._instance_count += 1

# ANTI-PATTERN: trying to reassign a Final
# Konfigurasi.MAX_KONEKSI = 200  # mypy error!

The dataclasses Module #

dataclasses is Python’s solution for removing boilerplate on classes whose purpose is storing data. With the @dataclass decorator, Python automatically generates __init__, __repr__, and __eq__ based on the fields you declare.

Basic Usage #

from dataclasses import dataclass

# ANTI-PATTERN: writing manual boilerplate
class ProdukManual:
    def __init__(self, nama: str, harga: float, stok: int):
        self.nama = nama
        self.harga = harga
        self.stok = stok

    def __repr__(self) -> str:
        return f"Produk(nama={self.nama!r}, harga={self.harga}, stok={self.stok})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, ProdukManual):
            return NotImplemented
        return (self.nama, self.harga, self.stok) == (other.nama, other.harga, other.stok)

# CORRECT: @dataclass generates all the boilerplate automatically
@dataclass
class Produk:
    nama: str
    harga: float
    stok: int

The result is identical, but the @dataclass code is far more concise and easier to maintain. Adding a new field only needs one line.

p1 = Produk("Laptop", 15_000_000, 10)
p2 = Produk("Laptop", 15_000_000, 10)

print(p1)           # Produk(nama='Laptop', harga=15000000.0, stok=10)
print(p1 == p2)     # True (generated automatically)

Default Values and field() #

Fields can have default values. For mutable defaults (list, dict), you must use field(default_factory=...):

from dataclasses import dataclass, field
from typing import Optional

@dataclass
class KeranjangBelanja:
    user_id: int
    items: list[str] = field(default_factory=list)  # CORRECT
    voucher: Optional[str] = None
    diskon: float = 0.0

    # ANTI-PATTERN that will error at runtime:
    # items: list[str] = []  # ValueError: mutable default

field() also allows more detailed per-field configuration:

from dataclasses import dataclass, field

@dataclass
class KonfigurasiServer:
    host: str
    port: int = 8080

    # repr=False: doesn't appear in __repr__
    password: str = field(default="", repr=False)

    # compare=False: not compared in __eq__
    metadata: dict = field(default_factory=dict, compare=False)

    # init=False: can't be set during initialization
    _koneksi_aktif: int = field(default=0, init=False, repr=False)

__post_init__ — Validation and Computation #

For logic that needs to run after initialization, use __post_init__:

from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class Transaksi:
    jumlah: float
    keterangan: str
    timestamp: datetime = field(default_factory=datetime.now)
    _id: str = field(init=False, repr=False)

    def __post_init__(self) -> None:
        # Validation
        if self.jumlah <= 0:
            raise ValueError(f"Transaction amount must be positive, got: {self.jumlah}")

        # Computing fields that depend on other fields
        self._id = f"TXN-{self.timestamp.strftime('%Y%m%d%H%M%S')}"

    @property
    def id(self) -> str:
        return self._id

t = Transaksi(jumlah=500_000, keterangan="Pay electricity bill")
print(t.id)  # TXN-20241215143022 (example)

Frozen, Order, and Slots Dataclasses #

Frozen Dataclasses #

frozen=True makes instances immutable — all fields are read-only after initialization. Good for value objects or dictionary keys:

from dataclasses import dataclass

@dataclass(frozen=True)
class KoordinatGPS:
    latitude: float
    longitude: float

    def jarak_ke(self, other: "KoordinatGPS") -> float:
        import math
        dlat = math.radians(other.latitude - self.latitude)
        dlon = math.radians(other.longitude - self.longitude)
        a = math.sin(dlat/2)**2 + math.cos(math.radians(self.latitude)) * \
            math.cos(math.radians(other.latitude)) * math.sin(dlon/2)**2
        return 6371 * 2 * math.asin(math.sqrt(a))  # km

jakarta = KoordinatGPS(-6.2088, 106.8456)
surabaya = KoordinatGPS(-7.2575, 112.7521)

print(f"Distance: {jakarta.jarak_ke(surabaya):.1f} km")  # ~664 km

# Frozen: can't be changed
# jakarta.latitude = -7.0  # FrozenInstanceError!

# Because frozen=True, it can be used as a dict key or set element
lokasi_dikunjungi = {jakarta, surabaya}

Order Comparison #

order=True generates the comparison methods __lt__, __le__, __gt__, __ge__ based on field order:

from dataclasses import dataclass

@dataclass(order=True)
class VersiApp:
    major: int
    minor: int
    patch: int

    def __str__(self) -> str:
        return f"{self.major}.{self.minor}.{self.patch}"

v1 = VersiApp(1, 2, 0)
v2 = VersiApp(1, 3, 0)
v3 = VersiApp(2, 0, 0)

versi = [v3, v1, v2]
versi.sort()
print(versi)  # [1.2.0, 1.3.0, 2.0.0]
print(v1 < v2)  # True

Slots #

Python 3.10+ supports slots=True on dataclasses, which uses __slots__ under the hood for memory efficiency:

from dataclasses import dataclass

# slots=True: more memory-efficient, faster attribute access
@dataclass(slots=True)
class Sensor:
    id: str
    nilai: float
    satuan: str

# Great for objects created in very large quantities
pembacaan = [Sensor(f"S{i}", i * 0.5, "°C") for i in range(100_000)]
flowchart TD
    A["@dataclass"] --> B[Configuration Parameters]
    B --> C["frozen=True\nImmutable instance\nHashable as dict key"]
    B --> D["order=True\nComparisons <, >, <=, >=\nSortable"]
    B --> E["slots=True\nMemory efficient\nFaster access"]
    B --> F["eq=False\nNon-equal comparison\nCustom __eq__"]

Combining Typing and Dataclasses #

The real power appears when you combine precise type hints with dataclasses:

from dataclasses import dataclass, field
from typing import Optional, Literal
from datetime import datetime

StatusPesanan = Literal["menunggu", "diproses", "dikirim", "selesai", "dibatalkan"]

@dataclass
class ItemPesanan:
    produk_id: int
    nama_produk: str
    harga_satuan: float
    jumlah: int

    @property
    def subtotal(self) -> float:
        return self.harga_satuan * self.jumlah

@dataclass
class Pesanan:
    id: int
    user_id: int
    items: list[ItemPesanan] = field(default_factory=list)
    status: StatusPesanan = "menunggu"
    catatan: Optional[str] = None
    dibuat_pada: datetime = field(default_factory=datetime.now)
    diperbarui_pada: Optional[datetime] = None

    def __post_init__(self) -> None:
        if not self.items:
            raise ValueError("An order must have at least one item")

    @property
    def total(self) -> float:
        return sum(item.subtotal for item in self.items)

    def ubah_status(self, status_baru: StatusPesanan) -> None:
        transisi_valid: dict[StatusPesanan, list[StatusPesanan]] = {
            "menunggu": ["diproses", "dibatalkan"],
            "diproses": ["dikirim", "dibatalkan"],
            "dikirim": ["selesai"],
            "selesai": [],
            "dibatalkan": [],
        }
        if status_baru not in transisi_valid[self.status]:
            raise ValueError(
                f"Cannot change status from '{self.status}' to '{status_baru}'"
            )
        self.status = status_baru
        self.diperbarui_pada = datetime.now()

# Usage
pesanan = Pesanan(
    id=1001,
    user_id=42,
    items=[
        ItemPesanan(1, "Laptop", 15_000_000, 1),
        ItemPesanan(2, "Mouse", 250_000, 2),
    ]
)

print(f"Total: Rp {pesanan.total:,.0f}")  # Total: Rp 15.500.000
pesanan.ubah_status("diproses")
print(pesanan.status)  # diproses

When to Use What #

Use type hints alone if:
  ✓ Existing classes that only need type annotations
  ✓ Functions needing parameter and return type documentation
  ✓ You want mypy/pyright to validate the code
  ✓ Shared codebases needing clear contracts

Use @dataclass if:
  ✓ Classes whose main function is storing data (value objects, DTOs, entities)
  ✓ You need automatically consistent __repr__ and __eq__
  ✓ You need ordering comparisons between instances
  ✓ You need immutability (frozen=True)

Use TypedDict if:
  ✓ Working with dictionaries whose key structure is already known
  ✓ Integration with JSON APIs that return dicts
  ✓ You don't want a full class but still need type safety

Consider namedtuple or NamedTuple if:
  ✗ You need truly immutable and hashable objects without frozen=True
  ✗ You need tuple interop (unpacking, indexing)

Summary #

  • Type hints don’t change runtime — they’re metadata for tooling (mypy, IDEs, pyright). The program runs the same with or without type hints.
  • Optional[X] is Union[X, None] — use it for values that can be None. In Python 3.10+, the X | None syntax is cleaner.
  • TypeVar for generic functions/classes — preserves type consistency between input and output without sacrificing flexibility.
  • Protocol for type-safe duck typing — more flexible than abstract base classes because it doesn’t require explicit inheritance.
  • @dataclass removes boilerplate__init__, __repr__, and __eq__ are generated automatically from field declarations.
  • Always use field(default_factory=...) for mutable defaultsitems: list = [] causes bugs because all instances share the same list.
  • frozen=True for value objects — makes instances immutable and hashable, good for dict keys or set elements.
  • __post_init__ for validation and computation — run logic after fields are initialized, including constraint validation and derived field calculations.
  • TypedDict for structured dicts — a lightweight alternative to dataclasses when working with JSON or dicts whose keys are already known.

← Previous: Itertools & Functools
About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact