Tipe primitif seperti u32, f64, dan String sanggup menampung hampir semua data: panjang ruangan, harga tiket, nomor identitas pelanggan, sampai alamat email. Kelenturan tersebut menyimpan risiko yang sering diremehkan: angka 500 yang kita maksudkan sebagai milimeter dapat lolos ke fungsi yang menghitung dalam satuan meter tanpa satu pun peringatan, karena kedua nilai bertipe u32 yang sama persis. Bug akibat tertukarnya satuan seperti ini pernah terjadi di dunia nyata dengan konsekuensi mahal — pesawat antariksa Mars Climate Orbiter hilang pada tahun 1999 karena satu tim mengirim data dalam satuan pound sementara tim lain membacanya sebagai newton. Newtype pattern adalah jawaban Rust atas masalah tersebut: kita membungkus tipe primitif ke dalam tuple struct satu field sehingga nilai memperoleh identitas tipe yang berdiri sendiri, lengkap dengan tempat untuk menempatkan aturan validasi.

Masalah Satuan yang Saling Tertukar #

Bayangkan kita menulis fungsi penghitung panjang total dua ruangan. Dengan tipe primitif murni, signature-nya berbentuk fn calculate_total_length(length_a: u32, length_b: u32) -> u32. Signature tersebut menerima angka u32 dari mana saja: meter, milimeter, harga tiket, bahkan nomor kursi bioskop. Kompilator tidak melihat ada yang salah ketika pemanggil mencampur satuan, karena menurut sistem tipe semua angka tersebut sama. Bug satuan semacam ini biasanya hanya ketahuan saat pengujian — atau lebih buruk, ketika hasil perhitungan yang salah sudah dipakai di produksi.

Masalah yang sama muncul pada data domain. Nilai 0 untuk id pengguna sering kali bukan id yang sah, dan teks tanpa tanda @ bukan alamat email yang valid, tetapi tipe u64 dan String tetap menerima keduanya tanpa bertanya. Kita membutuhkan cara untuk memberi tahu kompilator: “angka ini adalah milimeter”, “angka itu adalah id pengguna yang sudah divalidasi”. Newtype pattern memberi kita cara tersebut tanpa biaya runtime.

Newtype sebagai Zero-Cost Wrapper #

Newtype dibuat dengan tuple struct berisi tepat satu field. Konstruksi semacam ini menghasilkan tipe baru yang benar-benar berbeda dari tipe di dalamnya menurut kompilator, meskipun layout memorinya identik. Mari kita terapkan pada kasus satuan:

rust
struct Millimeters(u32);
struct Meters(u32);

fn calculate_total_length(length_a: Meters, length_b: Meters) -> Meters {
    Meters(length_a.0 + length_b.0)
}

fn main() {
    let base_length = Meters(3);
    let extra_length = Meters(2);

    let total = calculate_total_length(base_length, extra_length);
    println!("Total panjang: {} meter", total.0);
}

Output yang Dihasilkan:

text
Total panjang: 5 meter

Mari kita bedah kode di atas baris per baris:

  1. Baris 1 (struct Millimeters(u32);): mendefinisikan newtype Millimeters yang membungkus satu nilai u32. Mulai baris ini, Millimeters adalah tipe tersendiri — bukan u32.
  2. Baris 2 (struct Meters(u32);): mendefinisikan newtype Meters dengan isi u32 yang sama, tetapi sebagai tipe yang berbeda dari Millimeters.
  3. Baris 4 (fn calculate_total_length(length_a: Meters, length_b: Meters) -> Meters): fungsi kini hanya menerima dan mengembalikan Meters. Kontrak satuan fungsi terlihat langsung dari signature.
  4. Baris 5 (Meters(length_a.0 + length_b.0)): angka 0 setelah nama variabel mengakses satu-satunya field tuple struct. Penjumlahan dua u32 dibungkus kembali menjadi Meters agar hasil tetap membawa identitas satuannya.
  5. Baris 9 (let base_length = Meters(3);): membuat nilai Meters bernilai 3. Angka 3 tidak lagi bisa diperlakukan sebagai u32 biasa.
  6. Baris 10 (let extra_length = Meters(2);): membuat nilai Meters kedua dengan pola yang sama.
  7. Baris 12 (let total = calculate_total_length(base_length, extra_length);): kedua argumen bertipe Meters, sehingga pemanggilan lolos pemeriksaan tipe.
  8. Baris 13 (println!("Total panjang: {} meter", total.0);): membaca isi total lewat field .0 untuk dicetak. Karena fungsi dan pemanggil sepakat memakai Meters, hasil 5 benar-benar berarti 5 meter.

Kompilator Menangkap Satuan yang Tertukar #

Kekuatan newtype terlihat justru ketika kita salah. Misalkan rekan tim menambahkan nilai presisi dalam Millimeters lalu tanpa sengaja mengirimkannya ke fungsi yang menuntut Meters:

rust
struct Millimeters(u32);
struct Meters(u32);

fn calculate_total_length(length_a: Meters, length_b: Meters) -> Meters {
    Meters(length_a.0 + length_b.0)
}

fn main() {
    let base_length = Meters(3);
    let precision = Millimeters(500);

    let total = calculate_total_length(base_length, precision);
    println!("Total panjang: {} meter", total.0);
}

Program di atas tidak dapat dikompilasi. Pesan error lengkapnya:

text
error[E0308]: mismatched types
  --> main.rs:12:53
   |
12 |     let total = calculate_total_length(base_length, precision);
   |                 ----------------------              ^^^^^^^^^ expected `Meters`, found `Millimeters`
   |                 |
   |                 arguments to this function are incorrect
   |
note: function defined here
  --> main.rs:4:4
   |
 4 | fn calculate_total_length(length_a: Meters, length_b: Meters) -> Meters {
   |    ^^^^^^^^^^^^^^^^^^^^^^                   ----------------

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

Pesan E0308 menyatakan persoalannya secara gamblang: parameter kedua menuntut Meters, tetapi pemanggil mengirim Millimeters. Bug satuan yang tadinya diam-diam bersembunyi sampai runtime kini tertangkap sebelum program sempat jalan. Kompilator tidak bisa menolong dengan cara yang sama jika kedua satuan sama-sama bertipe u32 — pengecekan seperti inilah yang hilang bila kita memakai type alias (topik Chapter berikutnya) alih-alih newtype.

Newtype sebagai Abstraksi Zero-Cost #

Pembungkusan ini sering memunculkan kekhawatiran: apakah ada biaya performa karena satu lapis struct tambahan? Jawabannya tidak. Karena newtype hanya punya satu field, kompilator memberi layout memori yang identik dengan tipe yang dibungkusnya. Kita bisa membuktikannya sendiri dengan std::mem::size_of:

rust
struct Millimeters(u32);

fn main() {
    println!("Ukuran Millimeters: {} byte", std::mem::size_of::<Millimeters>());
    println!("Ukuran u32        : {} byte", std::mem::size_of::<u32>());
}

Output yang Dihasilkan:

text
Ukuran Millimeters: 4 byte
Ukuran u32        : 4 byte

Angka pada kedua baris output sama: Millimeters menempati 4 byte, tepat seperti u32 yang dibungkusnya. Tidak ada byte tambahan, tidak ada indirection, tidak ada biaya penyalinan yang lebih mahal. Inilah yang dimaksud istilah zero-cost abstraction — keamanan tipe penuh tanpa satu pun overhead runtime. Pengecekan tipe terjadi seluruhnya di fase kompilasi, dan binary hasil kompilasi memperlakukan Millimeters seperti u32 biasa.

Validasi Domain dengan Newtype #

Manfaat kedua newtype adalah tempat menempatkan validasi. Polanya: jadikan satu-satunya field sebagai penyimpan data mentah, lalu sediakan constructor yang memeriksa data sebelum membungkusnya. Nilai dari tipe domain yang sah kemudian hanya bisa lahir melalui constructor tersebut. Mari kita terapkan pada UserId:

rust
struct UserId(u64);

impl UserId {
    fn new(id: u64) -> Result<UserId, String> {
        if id == 0 {
            Err(String::from("id pengguna tidak boleh nol"))
        } else {
            Ok(UserId(id))
        }
    }
}

fn main() {
    match UserId::new(42) {
        Ok(user_id) => println!("Id pengguna valid: {}", user_id.0),
        Err(message) => println!("Gagal: {}", message),
    }
    match UserId::new(0) {
        Ok(user_id) => println!("Id pengguna valid: {}", user_id.0),
        Err(message) => println!("Gagal: {}", message),
    }
}

Output yang Dihasilkan:

text
Id pengguna valid: 42
Gagal: id pengguna tidak boleh nol

Mari kita bedah bagian pentingnya:

  1. Baris 3 (impl UserId {): blok implementasi milik UserId menampung constructor dan method yang menjaga aturan domain.
  2. Baris 4 (fn new(id: u64) -> Result<UserId, String>): constructor mengembalikan Result, bukan UserId langsung, karena input mentah bisa jadi tidak sah.
  3. Baris 5-6 (if id == 0 { Err(...) }): aturan domain “id tidak boleh nol” diperiksa di satu-satunya pintu masuk. Setiap pemegang UserId setelah ini dijamin bukan nol.
  4. Baris 14 dan 18 (dua blok match): setiap pemanggilan constructor dipaksa menghadapi kedua kemungkinan lewat match. Blok pertama (baris 14, id 42) menghasilkan Ok; blok kedua (baris 18, id 0) menghasilkan Err — jalur nilai tidak sah tidak mungkin terlewat begitu saja.

Pola yang sama bekerja untuk tipe bersaringan teks seperti EmailAddress:

rust
#[derive(Debug)]
struct EmailAddress(String);

impl EmailAddress {
    fn parse(input: &str) -> Result<EmailAddress, String> {
        if input.contains('@') && !input.starts_with('@') && !input.ends_with('@') {
            Ok(EmailAddress(String::from(input)))
        } else {
            Err(format!("teks '{}' bukan alamat email yang valid", input))
        }
    }
}

fn main() {
    let email = EmailAddress::parse("[email protected]");
    let invalid = EmailAddress::parse("budi.example.com");

    println!("Hasil parse valid: {:?}", email);
    println!("Hasil parse salah: {:?}", invalid);
}

Output yang Dihasilkan:

text
Hasil parse valid: Ok(EmailAddress("[email protected]"))
Hasil parse salah: Err("teks 'budi.example.com' bukan alamat email yang valid")

Baris #[derive(Debug)] pada definisi struct memungkinkan pencetakan Result<EmailAddress, String> dengan {:?}. Validasi pada parse memang sederhana — hanya memeriksa keberadaan @ di posisi tengah — tetapi konsepnya bisa diperluas dengan aturan domain yang lebih ketat tanpa mengubah pola pemanggilannya.

Newtype Tidak Mewarisi Trait #

Satu kejutan bagi pemula: newtype tidak mewarisi implementasi trait dari tipe yang dibungkusnya. u32 bisa dibandingkan dengan ==, tetapi UserId belum bisa sampai kita menambahkan trait yang dibutuhkan:

rust
struct UserId(u64);

fn main() {
    let id = UserId(42);
    println!("Apakah sama? {}", id == UserId(42));
}

Kode di atas gagal dikompilasi dengan pesan berikut:

text
error[E0369]: binary operation `==` cannot be applied to type `UserId`
 --> main.rs:5:36
  |
5 |     println!("Apakah sama? {}", id == UserId(42));
  |                                 -- ^^ ---------- UserId
  |                                 |
  |                                 UserId
  |
note: an implementation of `PartialEq` might be missing for `UserId`
 --> main.rs:1:1
  |
1 | struct UserId(u64);
  | ^^^^^^^^^^^^^ must implement `PartialEq`
help: consider annotating `UserId` with `#[derive(PartialEq)]`
  |
1 + #[derive(PartialEq)]
2 | struct UserId(u64);
  |

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0369`.

Pesan E0369 sekaligus memberikan solusinya: tambahkan #[derive(PartialEq)]. Setelah kita mengikuti saran tersebut, perbandingan id == UserId(42) menghasilkan true dan pencetakan {:?} juga ikut bekerja dengan menambahkan Debug ke daftar derive:

rust
#[derive(PartialEq, Debug)]
struct UserId(u64);

Ringkasan #

Newtype pattern membungkus tipe primitif dalam tuple struct satu field untuk menciptakan tipe beridentitas sendiri: Millimeters dan Meters sama-sama berisi u32, tetapi pengecampuran keduanya ditolak kompilator lewat error E0308 sebelum program berjalan. Abstraksi ini zero-cost karena layout memorinya identik dengan tipe yang dibungkus, sebagaimana dibuktikan size_of yang menunjukkan 4 byte untuk keduanya. Constructor seperti UserId::new dan EmailAddress::parse menjadikan newtype sebagai rumah bagi validasi domain, dan derive macro menutup celah trait yang tidak diwarisi otomatis. Newtype memang menciptakan tipe baru; Chapter berikutnya membahas type alias — cara lain menamai tipe yang justru tidak menciptakan tipe baru sama sekali.