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 overuseOptional. If a function always returns a value (neverNone), don’t give it anOptionaltype. OverusingOptionalfills code with unnecessaryif x is not Nonechecks and obscures the cases whereNoneis 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]isUnion[X, None]— use it for values that can beNone. In Python 3.10+, theX | Nonesyntax is cleaner.TypeVarfor generic functions/classes — preserves type consistency between input and output without sacrificing flexibility.Protocolfor type-safe duck typing — more flexible than abstract base classes because it doesn’t require explicit inheritance.@dataclassremoves boilerplate —__init__,__repr__, and__eq__are generated automatically from field declarations.- Always use
field(default_factory=...)for mutable defaults —items: list = []causes bugs because all instances share the same list.frozen=Truefor 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.TypedDictfor structured dicts — a lightweight alternative to dataclasses when working with JSON or dicts whose keys are already known.
← Previous: Itertools & Functools