Typing & Data Classes #

Python adalah bahasa yang dinamis — kamu bisa memasukkan nilai apapun ke variabel manapun tanpa deklarasi tipe. Fleksibilitas ini menyenangkan saat prototyping, tapi menjadi bumerang di codebase besar: bug tersembunyi akibat tipe yang salah baru ketahuan saat runtime, bukan saat kamu menulis kode. Modul typing hadir untuk mengisi celah ini dengan sistem type hints yang memungkinkan tool seperti mypy dan IDE modern mendeteksi kesalahan tipe sebelum program dijalankan. Di sisi lain, dataclasses menyederhanakan pembuatan kelas yang fungsi utamanya menyimpan data — menghilangkan boilerplate __init__, __repr__, dan __eq__ yang membosankan. Keduanya bukan fitur opsional untuk kode produksi; keduanya adalah fondasi kode Python yang mudah dipahami, di-maintain, dan di-test.

Mengapa Type Hints Penting #

Sebelum masuk ke sintaks, penting untuk memahami apa yang sebenarnya diselesaikan type hints. Bayangkan sebuah fungsi yang menerima parameter user — apakah itu string nama, integer ID, atau objek User? Tanpa type hints, kamu harus membaca implementasinya atau berharap ada dokumentasi yang up-to-date. Type hints mengubah niat kode menjadi bagian dari kode itu sendiri.

# ANTI-PATTERN: tidak ada informasi tipe sama sekali
def proses_pesanan(user, items, diskon):
    total = sum(item["harga"] for item in items)
    if diskon:
        total *= (1 - diskon)
    return total

# BENAR: type hints membuat kontrak fungsi eksplisit
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 tidak mengubah perilaku program di runtime — Python tetap tidak memvalidasi tipe secara otomatis. Yang berubah adalah kemampuan tooling: IDE bisa memberikan autocomplete yang akurat, mypy bisa menemukan bug tipe, dan pembaca kode berikutnya (termasuk kamu sendiri enam bulan ke depan) bisa langsung memahami kontrak fungsi tanpa menggali implementasi.

flowchart LR
    A[Kode Python dengan Type Hints] --> B[mypy / pyright]
    A --> C[IDE / Editor]
    A --> D[Runtime Python]
    B --> E[Error tipe terdeteksi sebelum run]
    C --> F[Autocomplete & inline docs akurat]
    D --> G[Tidak ada validasi tipe — performa sama]

Tipe Dasar dari Modul typing #

Modul typing menyediakan building block untuk mendeskripsikan tipe yang lebih kompleks dari sekadar int, str, atau bool. Sejak Python 3.9+, banyak dari tipe ini sudah bisa digunakan langsung dari built-in (list[int] bukan List[int]), tapi memahami versi typing tetap penting untuk kompatibilitas dengan codebase yang lebih lama.

Optional dan Union #

Optional[X] adalah shorthand untuk Union[X, None] — artinya nilai boleh bertipe X atau None. Ini adalah salah satu type hint yang paling sering digunakan karena nilai nullable sangat umum.

from typing import Optional, Union

# Optional[str] berarti nilai bisa str atau None
def cari_user(email: str) -> Optional[dict]:
    # mengembalikan dict jika ditemukan, None jika tidak
    ...

# Union memungkinkan beberapa tipe sekaligus
def format_nilai(nilai: Union[int, float]) -> str:
    return f"{nilai:.2f}"

# Python 3.10+: sintaks | yang lebih bersih
def format_nilai_modern(nilai: int | float) -> str:
    return f"{nilai:.2f}"
Jangan overuse Optional. Jika sebuah fungsi selalu mengembalikan nilai (tidak pernah None), jangan beri tipe Optional. Overuse Optional membuat kode penuh if x is not None yang tidak perlu dan mengaburkan kasus di mana None memang bermakna.

List, Dict, Tuple, dan Set #

Untuk koleksi, kamu bisa mendeskripsikan tipe elemen di dalamnya menggunakan generic syntax:

from typing import List, Dict, Tuple, Set  # gaya lama, Python < 3.9

# Python 3.9+: gunakan built-in langsung
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 dengan panjang tetap
def koordinat() -> tuple[float, float]:
    return (1.2, 3.4)

# Tuple dengan panjang variabel (homogen)
def daftar_nilai() -> tuple[int, ...]:
    return (1, 2, 3, 4, 5)

Callable #

Callable digunakan untuk mendeskripsikan fungsi sebagai parameter atau return value:

from typing import Callable

# Callable[[tipe_arg1, tipe_arg2], tipe_return]
def terapkan_transformasi(
    data: list[int],
    transformasi: Callable[[int], int]
) -> list[int]:
    return [transformasi(x) for x in data]

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

# Fungsi yang mengembalikan fungsi
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 dan Generic #

Kadang kamu ingin membuat fungsi yang bekerja pada berbagai tipe tapi tetap mempertahankan konsistensi tipe — misalnya, fungsi yang menerima list[T] dan mengembalikan T. Di sinilah TypeVar dan Generic berperan.

TypeVar #

TypeVar mendefinisikan variabel tipe yang bisa diisi tipe apapun saat digunakan, dengan constraint tertentu jika perlu:

from typing import TypeVar

T = TypeVar("T")

# Fungsi ini menerima list apapun dan mengembalikan elemen pertamanya
# dengan tipe yang sama persis
def ambil_pertama(items: list[T]) -> T:
    return items[0]

# mypy tahu bahwa hasilnya adalah int
angka: int = ambil_pertama([1, 2, 3])

# mypy tahu bahwa hasilnya adalah str
kata: str = ambil_pertama(["halo", "dunia"])

Kamu juga bisa membatasi TypeVar hanya untuk tipe tertentu menggunakan bound atau daftar tipe yang diizinkan:

from typing import TypeVar

# T harus subclass dari 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 Class #

Kelas bisa dibuat generic dengan mewarisi 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 tahu ini adalah Stack[int]
stack_angka: Stack[int] = Stack()
stack_angka.push(1)
stack_angka.push(2)
nilai: Optional[int] = stack_angka.pop()  # tipe: 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 bertipe int]
    D --> G[push/pop bertipe str]
    E --> H[push/pop bertipe UserModel]

Protocol — Structural Subtyping #

Protocol adalah fitur dari typing yang memungkinkan duck typing yang type-safe. Alih-alih mengharuskan kelas mewarisi interface tertentu (nominal typing), Protocol memeriksa apakah kelas memiliki method yang dibutuhkan (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"Menggambar lingkaran di ({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"Menggambar persegi di ({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)

# Kedua kelas ini tidak perlu mewarisi Drawable secara eksplisit
# mypy tetap menerima keduanya sebagai 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 jauh lebih fleksibel dibanding abstract base class karena kamu tidak perlu mengubah kelas yang sudah ada — selama kelas itu memiliki method yang diperlukan, ia dianggap memenuhi Protocol.


Anotasi Tipe Lanjutan #

Literal #

Literal membatasi nilai ke konstanta tertentu, bukan tipe generik:

from typing import Literal

# Hanya menerima string "merah", "hijau", atau "biru"
def set_warna(warna: Literal["merah", "hijau", "biru"]) -> None:
    print(f"Warna diset ke: {warna}")

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

# Berguna untuk flag atau mode
Mode = Literal["baca", "tulis", "append"]

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

TypedDict #

TypedDict memungkinkan kamu mendefinisikan struktur dictionary dengan tipe per key:

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]  # field opsional (Python 3.11+)

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

# mypy akan error jika kamu akses key yang tidak ada
user: UserProfile = {
    "id": 1,
    "nama": "Budi",
    "email": "[email protected]",
    "alamat": {
        "jalan": "Jl. Merdeka No. 1",
        "kota": "Jakarta",
        "kode_pos": "10110",
        "negara": "Indonesia"
    }
}

Final dan ClassVar #

from typing import Final, ClassVar

class Konfigurasi:
    # ClassVar: atribut level kelas, bukan instance
    _instance_count: ClassVar[int] = 0

    # Final: nilai tidak boleh diubah setelah assignment
    MAX_KONEKSI: Final = 100
    NAMA_APP: Final[str] = "MyApp"

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

# ANTI-PATTERN: mencoba reassign Final
# Konfigurasi.MAX_KONEKSI = 200  # mypy error!

Modul dataclasses #

dataclasses adalah solusi Python untuk menghilangkan boilerplate pada kelas yang fungsinya menyimpan data. Dengan decorator @dataclass, Python otomatis menghasilkan __init__, __repr__, dan __eq__ berdasarkan field yang kamu deklarasikan.

Penggunaan Dasar #

from dataclasses import dataclass

# ANTI-PATTERN: menulis boilerplate manual
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)

# BENAR: @dataclass menghasilkan semua boilerplate secara otomatis
@dataclass
class Produk:
    nama: str
    harga: float
    stok: int

Hasilnya identik, tapi kode @dataclass jauh lebih ringkas dan lebih mudah di-maintain. Menambah field baru hanya perlu satu baris.

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 (dihasilkan otomatis)

Nilai Default dan field() #

Field bisa punya nilai default. Untuk nilai default yang mutable (list, dict), kamu wajib menggunakan 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)  # BENAR
    voucher: Optional[str] = None
    diskon: float = 0.0

    # ANTI-PATTERN yang akan error di runtime:
    # items: list[str] = []  # ValueError: mutable default

field() juga memungkinkan konfigurasi per-field yang lebih detail:

from dataclasses import dataclass, field

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

    # repr=False: tidak muncul di __repr__
    password: str = field(default="", repr=False)

    # compare=False: tidak dibandingkan di __eq__
    metadata: dict = field(default_factory=dict, compare=False)

    # init=False: tidak bisa di-set saat inisialisasi
    _koneksi_aktif: int = field(default=0, init=False, repr=False)

__post_init__ — Validasi dan Komputasi #

Untuk logika yang perlu dijalankan setelah inisialisasi, gunakan __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:
        # Validasi
        if self.jumlah <= 0:
            raise ValueError(f"Jumlah transaksi harus positif, dapat: {self.jumlah}")

        # Komputasi field yang bergantung pada field lain
        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="Bayar tagihan listrik")
print(t.id)  # TXN-20241215143022 (contoh)

Dataclass Frozen, Order, dan Slots #

Frozen Dataclass #

frozen=True membuat instance immutable — semua field read-only setelah inisialisasi. Cocok untuk value objects atau keys dalam dictionary:

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"Jarak: {jakarta.jarak_ke(surabaya):.1f} km")  # ~664 km

# Frozen: tidak bisa diubah
# jakarta.latitude = -7.0  # FrozenInstanceError!

# Karena frozen=True, bisa digunakan sebagai dict key atau set element
lokasi_dikunjungi = {jakarta, surabaya}

Order Comparison #

order=True menghasilkan method perbandingan __lt__, __le__, __gt__, __ge__ berdasarkan urutan field:

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+ mendukung slots=True pada dataclass, yang menggunakan __slots__ di balik layar untuk efisiensi memori:

from dataclasses import dataclass

# slots=True: lebih hemat memori, akses atribut lebih cepat
@dataclass(slots=True)
class Sensor:
    id: str
    nilai: float
    satuan: str

# Cocok untuk objek yang dibuat dalam jumlah sangat banyak
pembacaan = [Sensor(f"S{i}", i * 0.5, "°C") for i in range(100_000)]
flowchart TD
    A["@dataclass"] --> B[Parameter Konfigurasi]
    B --> C["frozen=True\nImmutable instance\nHashable sebagai dict key"]
    B --> D["order=True\nPerbandingan <, >, <=, >=\nBisa disort"]
    B --> E["slots=True\nHemat memori\nAkses lebih cepat"]
    B --> F["eq=False\nNon-equal comparison\nKustom __eq__"]

Menggabungkan Typing dan Dataclasses #

Kekuatan sesungguhnya muncul saat kamu menggabungkan type hints yang presisi dengan dataclass:

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("Pesanan harus memiliki minimal satu 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"Tidak bisa mengubah status dari '{self.status}' ke '{status_baru}'"
            )
        self.status = status_baru
        self.diperbarui_pada = datetime.now()

# Penggunaan
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

Kapan Menggunakan Apa #

Gunakan type hints saja jika:
  ✓ Kelas yang sudah ada dan hanya perlu diberi anotasi tipe
  ✓ Fungsi yang butuh dokumentasi tipe parameter dan return
  ✓ Ingin mypy/pyright bisa memvalidasi kode
  ✓ Codebase shared yang butuh kontrak yang jelas

Gunakan @dataclass jika:
  ✓ Kelas yang fungsi utamanya menyimpan data (value object, DTO, entity)
  ✓ Butuh __repr__ dan __eq__ yang otomatis konsisten
  ✓ Butuh perbandingan ordering antar instance
  ✓ Butuh immutability (frozen=True)

Gunakan TypedDict jika:
  ✓ Bekerja dengan dictionary yang struktur keynya sudah diketahui
  ✓ Integrasi dengan JSON API yang return dict
  ✓ Tidak ingin membuat kelas penuh tapi tetap butuh type safety

Pertimbangkan namedtuple atau NamedTuple jika:
  ✗ Butuh objek yang benar-benar immutable dan hashable tanpa frozen=True
  ✗ Butuh interop dengan tuple (unpacking, indexing)

Ringkasan #

  • Type hints tidak mengubah runtime — mereka adalah metadata untuk tooling (mypy, IDE, pyright). Program tetap berjalan sama dengan atau tanpa type hints.
  • Optional[X] adalah Union[X, None] — gunakan untuk nilai yang bisa None. Di Python 3.10+, sintaks X | None lebih bersih.
  • TypeVar untuk fungsi/kelas generik — mempertahankan konsistensi tipe antara input dan output tanpa mengorbankan fleksibilitas.
  • Protocol untuk duck typing yang type-safe — lebih fleksibel dari abstract base class karena tidak memerlukan inheritance eksplisit.
  • @dataclass menghilangkan boilerplate__init__, __repr__, dan __eq__ dihasilkan otomatis dari deklarasi field.
  • Selalu gunakan field(default_factory=...) untuk mutable defaultitems: list = [] akan menyebabkan bug karena semua instance berbagi list yang sama.
  • frozen=True untuk value objects — membuat instance immutable dan hashable, cocok untuk dict key atau set element.
  • __post_init__ untuk validasi dan komputasi — jalankan logika setelah field diinisialisasi, termasuk validasi constraint dan kalkulasi field turunan.
  • TypedDict untuk dict berstruktur — alternatif ringan untuk dataclass saat bekerja dengan JSON atau dict yang keynya sudah diketahui.

← Sebelumnya: Itertools & Functools  

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact