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:
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:
Total panjang: 5 meterMari kita bedah kode di atas baris per baris:
- Baris 1 (
struct Millimeters(u32);): mendefinisikan newtypeMillimetersyang membungkus satu nilaiu32. Mulai baris ini,Millimetersadalah tipe tersendiri — bukanu32. - Baris 2 (
struct Meters(u32);): mendefinisikan newtypeMetersdengan isiu32yang sama, tetapi sebagai tipe yang berbeda dariMillimeters. - Baris 4 (
fn calculate_total_length(length_a: Meters, length_b: Meters) -> Meters): fungsi kini hanya menerima dan mengembalikanMeters. Kontrak satuan fungsi terlihat langsung dari signature. - Baris 5 (
Meters(length_a.0 + length_b.0)): angka0setelah nama variabel mengakses satu-satunya field tuple struct. Penjumlahan duau32dibungkus kembali menjadiMetersagar hasil tetap membawa identitas satuannya. - Baris 9 (
let base_length = Meters(3);): membuat nilaiMetersbernilai 3. Angka 3 tidak lagi bisa diperlakukan sebagaiu32biasa. - Baris 10 (
let extra_length = Meters(2);): membuat nilaiMeterskedua dengan pola yang sama. - Baris 12 (
let total = calculate_total_length(base_length, extra_length);): kedua argumen bertipeMeters, sehingga pemanggilan lolos pemeriksaan tipe. - Baris 13 (
println!("Total panjang: {} meter", total.0);): membaca isitotallewat field.0untuk dicetak. Karena fungsi dan pemanggil sepakat memakaiMeters, 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:
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:
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:
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:
Ukuran Millimeters: 4 byte
Ukuran u32 : 4 byteAngka 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:
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:
Id pengguna valid: 42
Gagal: id pengguna tidak boleh nolMari kita bedah bagian pentingnya:
- Baris 3 (
impl UserId {): blok implementasi milikUserIdmenampung constructor dan method yang menjaga aturan domain. - Baris 4 (
fn new(id: u64) -> Result<UserId, String>): constructor mengembalikanResult, bukanUserIdlangsung, karena input mentah bisa jadi tidak sah. - Baris 5-6 (
if id == 0 { Err(...) }): aturan domain “id tidak boleh nol” diperiksa di satu-satunya pintu masuk. Setiap pemegangUserIdsetelah ini dijamin bukan nol. - Baris 14 dan 18 (dua blok
match): setiap pemanggilan constructor dipaksa menghadapi kedua kemungkinan lewatmatch. Blok pertama (baris 14, id 42) menghasilkanOk; blok kedua (baris 18, id 0) menghasilkanErr— jalur nilai tidak sah tidak mungkin terlewat begitu saja.
Pola yang sama bekerja untuk tipe bersaringan teks seperti EmailAddress:
#[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:
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:
struct UserId(u64);
fn main() {
let id = UserId(42);
println!("Apakah sama? {}", id == UserId(42));
}Kode di atas gagal dikompilasi dengan pesan berikut:
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:
#[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.