Aturan bisnis seperti “alamat email harus berformat sah” dan “sandi minimal delapan karakter” biasanya hidup sebagai pemeriksaan yang tersebar: setiap fungsi yang menerima string email merasa wajib memvalidasinya lagi, dan fungsi yang lupa memvalidasi diam-diam menerima data kotor. Newtype memutus penyebaran tersebut dengan menyimpan aturan di dalam tipe. Sebuah struct Email yang field-nya privat hanya bisa lahir lewat konstruktor Email::new() yang memvalidasi input dan mengembalikan Result — keberhasilan menghasilkan nilai bertipe Email adalah bukti bahwa aturan sudah terpenuhi. Setelah itu, setiap fungsi dengan parameter bertipe Email tidak perlu memvalidasi apa pun, karena compiler menolak string mentah yang mencoba masuk. Pada chapter ini kita membangun newtype Email dan Password untuk alur pendaftaran akun, lalu membuktikan bahwa upaya melewati validasi ditolak di pintu kompilasi.

Validasi yang Terserah Pemanggil #

Mari kita lihat dulu bentuk lama masalahnya. Fungsi pendaftaran dengan tanda tangan fn register(email: &str, password: &str) tidak memuat satu pun informasi aturan bisnis. Tipe &str menerima "[email protected]" dan "budi#example.com" dengan rasa yang sama, sehingga setiap lapisan yang ingin aman harus memvalidasi sendiri: handler HTTP memvalidasi, service memvalidasi lagi, dan fungsi penyimpanan tetap waswas. Validasi yang berulang tiga kali bukan tanda hati-hati, melainkan tanda tidak adanya jaminan — kalau setiap lapisan harus memeriksa, tidak ada lapisan yang bisa berhenti memeriksa.

Masalahnya lebih dalam dari sekadar duplikasi. Nilai &str tidak membawa sejarah: string yang sudah dua kali lolos pemeriksaan dan string yang belum pernah diperiksa sama-sama bertipe &str, jadi compiler tidak bisa membantu membedakannya. Konsep newtype yang sudah kita kenal dari fondasi Course menjadi kunci: bungkus nilai mentah ke dalam tipe baru, jadikan field-nya privat, dan pastikan satu-satunya jalan menciptakan tipe tersebut adalah konstruktor yang memvalidasi.

Newtype dengan Konstruktor Tervalidasi #

Program berikut mendefinisikan dua newtype domain — Email dan Password — masing-masing dengan error enum sendiri, lalu menyusun fungsi register yang menerima kedua tipe tersebut. Perhatikan bahwa seluruh validasi hanya terjadi di konstruktor:

rust
use std::fmt;

pub struct Email {
    // field privat: satu-satunya pintu masuk adalah Email::new
    value: String,
}

pub enum EmailError {
    Malformed,
    MissingDomainDot,
}

impl fmt::Display for EmailError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            EmailError::Malformed => write!(f, "format email tidak valid"),
            EmailError::MissingDomainDot => {
                write!(f, "domain harus memuat titik, misalnya example.com")
            }
        }
    }
}

impl Email {
    pub fn new(input: &str) -> Result<Self, EmailError> {
        let value = input.trim();
        let parts: Vec<&str> = value.split('@').collect();

        if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
            return Err(EmailError::Malformed);
        }

        if !parts[1].contains('.') {
            return Err(EmailError::MissingDomainDot);
        }

        Ok(Self { value: value.to_string() })
    }

    pub fn as_str(&self) -> &str {
        &self.value
    }
}

pub struct Password {
    value: String,
}

pub enum PasswordError {
    TooShort { received: usize },
    MissingDigit,
}

impl fmt::Display for PasswordError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            PasswordError::TooShort { received } => write!(
                f,
                "sandi minimal 8 karakter, diterima {} karakter",
                received
            ),
            PasswordError::MissingDigit => write!(f, "sandi harus memuat minimal satu angka"),
        }
    }
}

impl Password {
    pub fn new(input: &str) -> Result<Self, PasswordError> {
        let panjang = input.chars().count();
        if panjang < 8 {
            return Err(PasswordError::TooShort { received: panjang });
        }

        if !input.chars().any(|c| c.is_ascii_digit()) {
            return Err(PasswordError::MissingDigit);
        }

        Ok(Self { value: input.to_string() })
    }

    // getter tanpa awalan get_, sesuai konvensi Rust
    pub fn length(&self) -> usize {
        self.value.chars().count()
    }
}

// Pemanggil menerima jaminan: parameter sudah tervalidasi
fn register(email: Email, password: Password) -> String {
    format!("Akun {} terdaftar (sandi {} karakter)", email.as_str(), password.length())
}

fn main() {
    let inputs = [
        ("[email protected]", "rahasia123"),
        ("budi#example.com", "rahasia123"),
        ("[email protected]", "rahasia"),
        ("[email protected]", "sandikuat"),
    ];

    for (raw_email, raw_password) in inputs {
        let email = match Email::new(raw_email) {
            Ok(email) => email,
            Err(error) => {
                println!("Pendaftaran '{}' gagal: {}", raw_email, error);
                continue;
            }
        };
        let password = match Password::new(raw_password) {
            Ok(password) => password,
            Err(error) => {
                println!("Pendaftaran '{}' gagal: {}", raw_email, error);
                continue;
            }
        };
        println!("{}", register(email, password));
    }
}

Output yang Dihasilkan:

text
Akun [email protected] terdaftar (sandi 10 karakter)
Pendaftaran 'budi#example.com' gagal: format email tidak valid
Pendaftaran '[email protected]' gagal: sandi minimal 8 karakter, diterima 7 karakter
Pendaftaran '[email protected]' gagal: sandi harus memuat minimal satu angka

Mari kita bedah kode di atas:

  1. Baris 3-6 (pub struct Email { value: String }): newtype membungkus satu field. Field value tidak diberi pub, sehingga kode di luar modul tempat Email didefinisikan tidak bisa membaca apalagi menulis field tersebut secara langsung. Inilah yang membuat konstruktor menjadi pintu tunggal.
  2. Baris 25 (pub fn new(input: &str) -> Result<Self, EmailError>): konstruktor menerima string mentah dan mengembalikan Result. Kegagalan validasi adalah bagian dari tanda tangan, bukan panic tersembunyi — pemanggil wajib menangani kedua kemungkinan, sesuai pola section Penanganan Error.
  3. Baris 26-27 (let value = input.trim(); let parts ... split('@').collect()): validasi memecah teks pada tanda @. Email yang sah harus menghasilkan tepat dua bagian: nama lokal dan domain.
  4. Baris 29-35 (dua pemeriksaan if): aturan domain dinyatakan eksplisit — tepat satu @, kedua bagian tidak kosong, dan domain memuat titik. Setiap pelanggaran kembali sebagai varian EmailError yang berbeda, sehingga pemanggil bisa menampilkan pesan yang tepat.
  5. Baris 37 (Ok(Self { value: value.to_string() })): nilai baru hanya diciptakan setelah semua pemeriksaan lolos. Mulai baris ini, nilai bertipe Email adalah bukti berjalan bahwa formatnya sah.
  6. Baris 68-79 (impl Password { pub fn new ... }): Password menerapkan pola yang sama untuk aturan berbeda — panjang minimal delapan karakter dan memuat angka. Varian TooShort { received: usize } membawa data konteks berapa panjang yang diterima, pola enum error kustom yang sudah kita kenal.
  7. Baris 82-84 (pub fn length(&self) -> usize): newtype menyembunyikan representasi, jadi akses data diberikan lewat method. Nama length() tanpa awalan get_ mengikuti konvensi getter komunitas Rust — kita bahas lengkap pada chapter Rust API Guidelines.
  8. Baris 88 (fn register(email: Email, password: Password)): tanda tangan inilah hasil akhirnya. register tidak memvalidasi apa pun karena validasi sudah melekat pada tipe parameter — fungsi ini secara harfiah tidak bisa menerima sandi tujuh karakter.
  9. Baris 92-117 (fn main()): empat kombinasi data diuji; satu kombinasi lolos, tiga gagal di tempat yang berbeda. Pemanggilan validasi terjadi sekali per nilai, bukan sekali per fungsi penerima.

Field privat hanya bermakna bila ada batas yang dijaga. Aturan privasi Rust bekerja per modul, jadi contoh pembuktian berikut menempatkan Email di dalam modul email dan mencoba membangun nilai lewat struct literal dari luar modul tersebut. Simpan sebagai main.rs, lalu jalankan rustc --edition 2021 main.rs:

rust
mod email {
    pub struct Email {
        // field privat: hanya kode di dalam modul email yang boleh menyentuh
        value: String,
    }

    impl Email {
        pub fn new(input: &str) -> Result<Self, String> {
            if input.contains('@') {
                Ok(Self { value: input.to_string() })
            } else {
                Err(String::from("format email tidak valid"))
            }
        }

        pub fn as_str(&self) -> &str {
            &self.value
        }
    }
}

fn main() {
    // Jalur sah: lewat konstruktor yang memvalidasi
    match email::Email::new("[email protected]") {
        Ok(email) => println!("Email sah: {}", email.as_str()),
        Err(error) => println!("Ditolak: {}", error),
    }

    // Upaya melewati validasi lewat struct literal
    let gelap = email::Email { value: String::from("admin@root") };
    println!("Email gelap: {}", gelap.as_str());
}

Pesan error kompilator:

text
error[E0451]: field `value` of struct `Email` is private
  --> main.rs:30:32
   |
30 |     let gelap = email::Email { value: String::from("admin@root") };
   |                                ^^^^^ private field

error: aborting due to 1 previous error

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

Error E0451 menutup pintu belakang: konstruktor literal email::Email { value: ... } ditolak karena field value hanya terlihat dari dalam modul email. Nilai "admin@root" yang tidak akan pernah lolos validasi new() juga tidak akan pernah masuk lewat jalur ini. Pada program contoh satu file, seluruh kode berada di modul crate root sehingga privasi berlaku hanya setelah kita memisahkan modul — pada crate nyata, newtype domain hidup di modulnya sendiri dan seluruh batas ini berlaku penuh, sesuai materi section Modul dan Visibilitas.

Jaminan yang Mengalir ke Seluruh Sistem #

Kekuatan newtype baru terasa ketika jaminannya mengalir ke tanda tangan pemanggil lain. Fungsi save_contact(email: Email), send_welcome(email: &Email), dan register(email: Email, password: Password) semuanya menerima dokumentasi yang ditegakkan compiler: parameter tersebut sudah tervalidasi. Pemanggil yang mencoba mengirim "budi#example.com" langsung ditolak dengan error tipe mismatch — E0308, error yang sudah kita kenal sejak chapter Referensi dan Borrowing — karena &str dan Email adalah tipe yang berbeda. Bandingkan dengan versi &str: satu-satunya penjaga adalah kebiasaan tiap fungsi memvalidasi sendiri.

Ringkasan #

Newtype untuk validasi domain bekerja dengan tiga lapis: field privat menutup akses langsung, konstruktor new() -> Result menjadi satu-satunya pintu masuk yang memeriksa aturan bisnis, dan tanda tangan fungsi bertipe Email atau Password menyebarkan jaminan ke seluruh sistem tanpa validasi ulang. Upaya bypass lewat struct literal ditolak compiler dengan E0451 selama newtype didefinisikan di modul terpisah dari pemanggilnya. Validasi yang tadinya tersebar kini terkumpul di satu titik pada batas sistem. Satu bidang desain API yang belum kita rapihkan adalah nasib kegagalan itu sendiri: ketika error akhirnya tampil ke pengguna, pesan apa yang boleh dibocorkan — pertanyaan yang kita jawab pada chapter Desain Error pada API.