Übergibst du eine u64-Nutzer-ID an eine Funktion, die eine Order-ID erwartet, beschwert sich Rust nicht. Beide sind u64. Der Compiler sieht identische Typen, also kann er dir nicht helfen. Du merkst es zur Laufzeit, meist in Produktion, meist nach einem Refactor, von dem du dachtest, er sei sicher.

Das ist genau die Bug-Klasse, die Newtypes eliminieren sollen.

Ein Newtype ist ein Tupel-Struct mit einem einzigen Feld, das einen bestehenden Typ wrappt: struct UserId(u64);. Zur Compile-Zeit sind UserId und OrderId inkompatibel. Zur Laufzeit belegen sie exakt dieselben Bytes wie ein nacktes u64. Keine zusätzlichen Allokationen, keine Indirektion, keine Kosten.

Welches Problem löst das tatsächlich?

Jede Sprache mit statischen Typen hat dieses Problem. Du hast zwei Werte mit derselben Repräsentation, aber unterschiedlicher Bedeutung. Datenbank-Keys, physikalische Einheiten, Währungsbeträge, Prozentsätze versus rohe Counts. In C würdest du ein typedef verwenden, in Go einen Type-Alias, aber das sind nur Namen für denselben zugrundeliegenden Typ. Der Compiler behandelt sie weiterhin als identisch.

Rusts Newtype-Pattern ist anders. struct UserId(u64); erzeugt einen eigenständigen Typ. Du kannst kein UserId dort übergeben, wo ein OrderId erwartet wird. Du kannst nicht aus Versehen einen Prozentsatz zu einem rohen Count addieren. Der Fehler tritt zur Compile-Zeit auf, nicht in einem Produktions-Incident.

Das ist es, was Leute meinen, wenn sie von „making illegal states unrepresentable“ sprechen. Es ist nicht theoretisch. Ich habe einmal einen API-Endpoint gesehen, der eine Account-ID und einen Transfer-Betrag entgegengenommen hat, beides u64. Ein Refactor hat sie im Caller vertauscht. Nichts ist während der Kompilierung kaputtgegangen. Geld ist an die falsche Stelle gelandet. Mit Newtypes wäre dieser Refactor fehlgeschlagen, bevor ihn jemand deployed hätte.

Wie Newtypes unter der Haube funktionieren

Ein Newtype in Rust ist einfach ein Struct mit einem unbenannten Feld:

struct UserId(u64);
struct OrderId(u64);

Der Compiler behandelt UserId als komplett separaten Typ von OrderId und von u64. Du konstruierst es explizit: let id = UserId(42);. Du greifst auf den inneren Wert mit id.0 zu.

Weil das Struct ein einziges Feld mit bekannter Größe hat, wendet der Compiler eine Optimierung an, die eigentlich gar keine Optimierung ist, sondern einfach so Structs funktionieren. Das Memory-Layout von UserId ist identisch mit u64. Dieselbe Größe, dieselbe Alignment, dieselbe ABI.

Das kannst du selbst verifizieren:

use std::mem;

assert_eq!(mem::size_of::<UserId>(), mem::size_of::<u64>());
assert_eq!(mem::align_of::<UserId>(), mem::align_of::<u64>());

Es gibt keine Vtable, keinen Discriminant, kein Wrapper-Objekt. Der generierte Machine Code, um ein UserId in eine Funktion zu übergeben, ist identisch mit dem für ein u64. Das Type System erzwingt die Unterscheidung. Die Runtime löscht sie.

Ein konkretes Beispiel: Pixel und Points verwechseln

Hier ist ein Pattern, das mich einmal gebissen hat. Eine Grafik-Bibliothek hatte Distanzen sowohl in Pixeln als auch in geräteunabhängigen Points. Beide waren f32. Ich habe Points übergeben, wo Pixel erwartet wurden, und mein UI wurde auf High-DPI-Screens halb so groß gerendert. Der Compiler war still, weil beide f32 waren.

Mit Newtypes:

struct Pixels(f32);
struct Points(f32);

fn scale_to_pixels(points: Points, dpi: f32) -> Pixels {
    Pixels(points.0 * dpi / 96.0)
}

fn draw_line(length: Pixels) {
    // render at this pixel length
}

fn main() {
    let width = Points(150.0);
    let dpi = 192.0;

    // This compiles:
    draw_line(scale_to_pixels(width, dpi));

    // This does not:
    // draw_line(width);
    // error: expected `Pixels`, found `Points`
}

Der Compiler lehnt den Fehler ab, bevor du das Programm jemals ausführst. Die Werte Points und Pixels verwenden dieselben vier Bytes wie f32. Die Sicherheit kostet zur Laufzeit nichts.

Die Trade-offs, über die dir niemand erzählt

Newtypes sind nicht umsonst zu schreiben. Die Methoden des gewrappten Typs propagieren nicht automatisch. Ein u64 hat wrapping_add, leading_zeros, Dutzende Methoden. Ein nacktes UserId hat keine davon, es sei denn, du implementierst sie selbst.

Du hast drei Optionen, und nur eine davon ist gut.

Option 1: Deref implementieren. Das gibt dir alle Methoden des inneren Typs durch Auto-Deref:

use std::ops::Deref;

struct UserId(u64);

impl Deref for UserId {
    type Target = u64;
    fn deref(&self) -> &u64 { &self.0 }
}

Das funktioniert, aber es macht den Zweck zunichte. Deref erlaubt eine implizite Coercion von UserId zu u64, was bedeutet, dass du ein UserId überall dort übergeben kannst, wo ein u64 erwartet wird. Du verlierst die Type Safety, die du dir gerade erkauft hast. Mach das nicht für Newtypes.

Option 2: Alles manuell implementieren. Das ist mühsam, aber du exposst nur die Operationen, die für deine Domain Sinn ergeben. Für einen ID-Typ brauchst du vielleicht nur Display, Debug, PartialEq, Eq und Hash:

use std::fmt;

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

impl fmt::Display for UserId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

Für einen numerischen Typ mit Arithmetik implementierst du Add, Sub und so weiter. Das ist Boilerplate. Die Standard-derive-Macros und Crates wie derive_more helfen, aber es ist immer noch mehr Code als ein roher Typ.

Option 3: Den inneren Wert direkt verwenden, wenn du ihn brauchst. Das ist meine Präferenz. Behalte den Newtype an API-Grenzen, unwrappe mit .0, wenn du den rohen Wert brauchst, und vermeide Deref komplett. Es ist etwas verbose, aber es bewahrt die Safety-Garantie.

Die Derive-Macros, die das erträglich machen

Rusts Standard-Library gibt dir #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] umsonst. Für Arithmetik greifst du zu std::ops oder einer Crate wie derive_more:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);

#[derive(Debug, Clone, Copy, PartialEq)]
struct Meters(f64);

impl std::ops::Add for Meters {
    type Output = Meters;
    fn add(self, other: Meters) -> Meters {
        Meters(self.0 + other.0)
    }
}

Der Compiler generiert immer noch denselben Machine Code wie für eine rohe f64-Addition. Das Add-Trait ist auch eine Zero-Cost-Abstraction. Es monomorphisiert, um die Operation zu inlinen.

Wann Newtypes das falsche Tool sind

Nicht jedes Primitive braucht einen Newtype. Wenn eine Funktion ein timeout_ms: u64 annimmt und es nur lokal verwendet wird, fügt das Wrapping Noise hinzu, ohne echte Bugs zu fangen. Reserviere Newtypes für Werte, die API-Grenzen überschreiten, in Datenbanken persistiert werden oder Konzepte repräsentieren, deren Verwechslung tatsächliche Konsequenzen hat.

Serialisierung wird auch komisch. Wenn du serde verwendest, serialisiert sich ein UserId(u64) standardmäßig als Map {"0": 42}, nicht als 42. Du brauchst #[serde(transparent)], um das zu fixen:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);

Das ist leicht zu vergessen und nervig zu debuggen, wenn deine JSON-API plötzlich Objekte statt Zahlen erwartet.

Fange mit deinen Public APIs an

Finde jede Funktion, die mehrere Argumente desselben Primitive-Typs, aber mit unterschiedlicher Bedeutung annimmt. Wrappe diejenigen, die Modul- oder Service-Grenzen überschreiten. Leite Deref nicht ab. Verwende #[serde(transparent)], wenn du serialisierst.

Wenn du cargo-expand installiert hast, führe cargo expand auf einem Newtype aus und schau dir den generierten Code an. Du wirst sehen, dass das Struct einfach der rohe Wert mit einem anderen Typ-Namen ist. Die Zero-Cost-Behauptung ist kein Marketing. Es ist buchstäblich das, was der Compiler emitted.