Sepanjang section ini kita mengumpulkan satu per satu instrumen keamanan tipe: type declaration native, variance, generics PHPDoc, hingga dua static analyzer yang siap dijalankan di CI. Instrumen hanya sekuat cara kita memakainya. Chapter penutup ini merangkai semuanya menjadi strategi praktis membangun kode yang type-safe — kode tempat kelas-kelas bug yang kita bahas sebelumnya sulit bahkan ditulis, bukan sekadar ditangkap setelah ditulis. Setiap strategi disertai contoh sebelum-sesudah yang dapat dijalankan langsung.

Peta Strategi #

Tujuh kebiasaan berikut membentuk tulang punggung codebase yang type-safe. Kita bahas satu per satu pada bagian berikutnya:

  1. Aktifkan strict_types=1 di setiap berkas PHP.
  2. Hindari mixed; jika terpaksa, tangani secara eksplisit.
  3. Pakai value object menggantikan primitif untuk konsep domain.
  4. Manfaatkan match exhaustive untuk enum.
  5. Validasi semua data di batas sistem sebelum masuk domain.
  6. Pakai assert() untuk invarian selama pengembangan.
  7. Pasang static analyzer sebagai gerbang CI.

strict_types di Setiap Berkas #

Tanpa declare(strict_types=1), PHP beroperasi dalam mode weak typing: argument type declaration tetap dijaga, tetapi skalar dikonversi diam-diam ketika mungkin. Perhatikan contoh berikut:

php
<?php

function kali(int $a, int $b): int
{
    return $a * $b;
}

echo kali('7', '6'), "\n";

Output yang Dihasilkan:

text
42

Kedua argumen adalah string, tetapi program mencetak 42 tanpa keluhan karena '7' dan '6' adalah string numerik yang berhasil dikonversi ke int secara senyap. Kesenjangan senyap inilah yang berbahaya: ' 7', '7.0', atau '7 tiga' akan diperlakukan berbeda-beda, dan bug data menjadi sulit dilacak. Dengan strict_types=1 pada baris pertama berkas, pemanggilan di atas langsung melempar TypeError — kegagalan terjadi di tepi fungsi, bukan di tengah perhitungan.

Hindari mixed, atau Tangani Secara Eksplisit #

mixed adalah pengakuan jujur bahwa tipe tidak diketahui — berguna sebagai tanda bahaya, berbahaya sebagai kebiasaan. Level 9 PHPStan bahkan melarang operasi apa pun terhadap mixed selain meneruskannya ke mixed lain. Satu-satunya cara menggunakannya dengan aman adalah menyempitkannya dulu:

php
<?php

declare(strict_types=1);

function panjangTeks(mixed $nilai): int
{
    if (is_string($nilai)) {
        return strlen($nilai);
    }
    if (is_int($nilai) || is_float($nilai)) {
        return strlen((string) $nilai);
    }
    if (is_null($nilai)) {
        return 0;
    }

    throw new InvalidArgumentException('Tipe tidak didukung');
}

echo panjangTeks('Belajar PHP'), "\n";
echo panjangTeks(2026), "\n";
echo panjangTeks(null), "\n";

Output yang Dihasilkan:

text
11
4
0

Rangkaian is_string() / is_int() / is_null() melakukan dua hal sekaligus: di runtime nilai diperiksa sungguhan, dan di mata analyzer tipe menyempit di tiap cabang (mixed → string, → int|float, → null). Cabang terakhir membuang tipe yang tak didukung dengan exception — tidak ada jalur diam-diam, tidak ada mixed yang lolos. Aturan praktisnya: setiap mixed yang masuk fungsi Anda harus keluar sebagai tipe pasti di semua cabang.

Value Object Menggantikan Primitif #

Integer 45000 bisa berarti apa saja: harga, stok, kode pos. Primitif tidak punya makna, dan kesalapan satuan atau nilai negatif tidak bisa dicegah oleh int. Value object membungkus primitif dengan kontrak dan validasinya sendiri:

php
<?php

declare(strict_types=1);

final class Harga
{
    public static function dariRupiah(int $rupiah): self
    {
        if ($rupiah < 0) {
            throw new InvalidArgumentException('Harga tidak boleh negatif');
        }

        return new self($rupiah);
    }

    private function __construct(public readonly int $rupiah)
    {
    }

    public function tambah(self $lain): self
    {
        return self::dariRupiah($this->rupiah + $lain->rupiah);
    }
}

$buku = Harga::dariRupiah(45000);
$pensil = Harga::dariRupiah(2000);
$total = $buku->tambah($pensil);

echo 'Total: Rp', $total->rupiah, "\n";

Output yang Dihasilkan:

text
Total: Rp47000

Mari kita bedah desain class Harga di atas:

  1. Constructor privat: Harga tidak bisa dibuat sembarangan; satu-satunya pintu adalah dariRupiah().
  2. Validasi di pintu masuk: nilai negatif ditolak di satu tempat, sehingga seluruh kode selanjutnya bebas dari pemeriksaan >= 0 yang tersebar.
  3. Method tambah(self $lain): penjumlahan hanya boleh antar dua Harga — mencampur Harga dengan stok int menjadi tidak mungkin ditulis, karena analyzer dan runtime sama-sama menolaknya.

Pola ini memindahkan kelas bug ke dalam compiler-analyzer: state yang tidak sah (harga negatif, Harga dicampur int) tidak bisa direpresentasikan sejak awal. Konsep domain yang layak dibungkus antara lain Harga, Email, NomorTelepon, dan KodePesanan.

match Exhaustive untuk Enum #

Saat satu konsep memiliki sejumlah kasus tetap, enum tanpa default membuat lupa-menangani-satu-kasus menjadi bug yang terdeteksi. Perhatikan contoh berikut:

php
<?php

declare(strict_types=1);

enum StatusPengiriman
{
    case Menunggu;
    case Dikirim;
    case Selesai;
    case Gagal;
}

function labelStatus(StatusPengiriman $status): string
{
    return match ($status) {
        StatusPengiriman::Menunggu => 'Menunggu kurir',
        StatusPengiriman::Dikirim => 'Sedang di jalan',
        StatusPengiriman::Selesai => 'Sudah tiba',
        StatusPengiriman::Gagal => 'Gagal dikirim',
    };
}

echo labelStatus(StatusPengiriman::Dikirim), "\n";

Output yang Dihasilkan:

text
Sedang di jalan

match tanpa lengan default bersifat exhaustive terhadap nilai yang diketahui saat ini. Ketika suatu hari Anda menambah case Dibatalkan, dua penjaga langsung menyalak: match tanpa default melempar Error di runtime, dan static analyzer menandai match yang tidak lagi mencakup semua anggota enum sebelum kode berjalan. Menambahkan default yang longgar justru mematikan kedua penjaga ini — pakai default hanya untuk nilai yang benar-benar boleh ditangani generik.

Jangan Percaya Batas Sistem #

Data dari luar — $_POST, payload API, hasil json_decode() — tidak pernah menjanjikan bentuk apa pun. Strateginya: tangkap di batas, validasi sekali, lalu ubah menjadi object domain bertipe. Bandingkan dua versi berikut. Versi rapuh yang menerima array mentah:

php
<?php

declare(strict_types=1);

function buatPesanEmail(array $data): string
{
    $nama = $data['nama'];
    $total = $data['total'];

    return "Halo $nama, total pesanan Anda Rp$total.\n";
}

echo buatPesanEmail(['nama' => 'Budi', 'total' => 150000]);

Output yang Dihasilkan:

text
Halo Budi, total pesanan Anda Rp150000.

Versi ini berhasil untuk data yang kebetulan benar, tetapi diam ketika 'total' berupa string 'lima puluh ribu', dan memicu Warning ketika 'nama' tidak ada. Seluruh fungsi turunan yang menerima $data mewarisi kerapuhan yang sama karena tidak ada kontrak. Versi tangguh memvalidasi di satu pintu lalu mengembalikan object bertipe:

php
<?php

declare(strict_types=1);

final class Pesanan
{
    public function __construct(
        public readonly string $nama,
        public readonly int $totalRupiah,
    ) {
        if ($this->totalRupiah < 0) {
            throw new InvalidArgumentException(
                'totalRupiah tidak boleh negatif'
            );
        }
    }

    public static function dariArray(array $data): self
    {
        $nama = $data['nama'] ?? null;
        $total = $data['total'] ?? null;

        if (!is_string($nama) || $nama === '') {
            throw new InvalidArgumentException(
                "Kolom 'nama' wajib berisi string tidak kosong"
            );
        }
        if (!is_int($total)) {
            throw new InvalidArgumentException(
                "Kolom 'total' wajib berisi integer"
            );
        }

        return new self($nama, $total);
    }

    public function pesanEmail(): string
    {
        return "Halo {$this->nama}, total pesanan Anda Rp{$this->totalRupiah}.\n";
    }
}

$pesanan = Pesanan::dariArray(['nama' => 'Budi', 'total' => 150000]);
echo $pesanan->pesanEmail();

try {
    Pesanan::dariArray(['nama' => 'Siti', 'total' => 'lima puluh ribu']);
} catch (InvalidArgumentException $e) {
    echo 'Ditolak di batas: ', $e->getMessage(), "\n";
}

Output yang Dihasilkan:

text
Halo Budi, total pesanan Anda Rp150000.
Ditolak di batas: Kolom 'total' wajib berisi integer

Method factory dariArray() adalah satu-satunya tempat mixed dunia luar disentuh: kunci dicek, tipe disempitkan, kesalahan ditolak dengan pesan jelas. Setelah itu, seluruh sisa program bekerja dengan Pesanan yang tipenya terjamin — fungsi pesanEmail() tidak perlu lagi ?? null atau is_int() di mana-mana. Semakin sempit zona mixed di tepi sistem, semakin luas zona aman yang dijamin analyzer di dalamnya.

assert untuk Invarian Selama Pengembangan #

Antara “tidak mungkin terjadi” dan “sangat yakin” ada assert(). Fungsi ini memeriksa asumsi internal yang seharusnya selalu benar jika kode sekitarnya benar:

php
<?php

declare(strict_types=1);

function rataRataUjian(array $nilai): float
{
    assert($nilai !== [], 'Daftar nilai tidak boleh kosong');

    return array_sum($nilai) / count($nilai);
}

echo rataRataUjian([80, 90, 100]), "\n";

Output yang Dihasilkan:

text
90

Jika suatu saat rataRataUjian() dipanggil dengan array kosong, assert() melempar AssertionError dengan pesan kita — kegagalan terjadi di titik asumsi dilanggar, bukan di pembagian nol tiga baris di bawahnya. Bedakan dengan validasi batas: assert() bukan untuk input pengguna (pakai if + exception seperti pada Pesanan::dariArray()), melainkan untuk invarian internal yang pelanggarannya berarti bug di kode lain.

Analisis sebagai Gerbang #

Strategi terakhir tidak berupa kode, melainkan kebiasaan: seluruh kekuatan di atas hanya terwujud jika diperiksa otomatis. Pastikan proyek Anda menjalankan PHPStan atau Psalm di CI dengan level yang hanya naik (lihat Chapter Baseline dan Analysis Levels ), menolak pull request yang menambah temuan, dan memperlakukan penurunan jumlah baseline sebagai kemajuan yang dirayakan. Dalam pengaturan seperti itu, setiap strategi pada Chapter ini bukan lagi disiplin manual yang bisa dilupakan — melainkan kontrak yang ditagih mesin pada setiap commit.

Ringkasan #

Type-safe code dibangun dari tujuh kebiasaan yang saling mengunci: strict_types menutup konversi senyap, penanganan mixed yang eksplisit menyempitkan ketidakpastian, value object mengubah primitif menjadi kontrak bermakna, match exhaustive menjadikan enum sulit dilupakan, validasi di batas memisahkan dunia luar dari domain yang terjamin, assert() menjaga invarian internal, dan CI memastikan semuanya ditegakkan tanpa bergantung pada ingatan manusia. Dengan section ini selesai, Anda memegang perangkat lengkap untuk menulis PHP modern yang tidak hanya berjalan benar, tetapi dapat dibuktikan benar — bekal yang akan terus Anda pakai di setiap section dan proyek berikutnya dalam Course ini.