Passez un identifiant utilisateur u64 à une fonction qui attend un identifiant de commande, et Rust ne se plaindra pas. Les deux sont des u64. Le compilateur voit des types identiques, donc il ne peut pas vous aider. Vous le découvrez au runtime, généralement en production, généralement après un refactor que vous pensiez sûr.

C’est exactement la classe de bugs que les newtypes existent pour éliminer.

Un newtype est un tuple struct à un seul champ qui enveloppe un type existant : struct UserId(u64);. À la compilation, UserId et OrderId sont incompatibles. Au runtime, ils occupent exactement les mêmes octets qu’un u64 brut. Pas d’allocations supplémentaires, pas d’indirection, pas de coût.

Quel problème cela résout-il vraiment ?

Tout langage avec des types statiques a ce problème. Vous avez deux valeurs avec la même représentation mais des significations différentes. Clés de base de données, unités physiques, montants de devises, pourcentages contre compteurs bruts. En C vous utiliseriez un typedef, en Go un alias de type, mais ce ne sont que des noms pour le même type sous-jacent. Le compilateur les traite toujours comme identiques.

Le pattern newtype de Rust est différent. struct UserId(u64); crée un type distinct. Vous ne pouvez pas passer un UserId là où un OrderId est attendu. Vous ne pouvez pas accidentellement ajouter un pourcentage à un compteur brut. L’erreur apparaît à la compilation, pas dans un incident de production.

C’est ce que les gens veulent dire par « rendre les états illégaux non-représentables ». Ce n’est pas théorique. J’ai un jour vu un endpoint d’API qui acceptait un identifiant de compte et un montant de transfert, tous deux u64. Un refactor les a inversés dans un appelant. Rien n’a cassé pendant la compilation. De l’argent est allé au mauvais endroit. Avec des newtypes, ce refactor aurait échoué à la compilation avant que quiconque ne le déploie.

Comment les newtypes fonctionnent sous le capot

Un newtype en Rust n’est qu’un struct avec un champ sans nom :

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

Le compilateur traite UserId comme un type complètement séparé de OrderId et de u64. Vous le construisez explicitement : let id = UserId(42);. Vous accédez à la valeur interne avec id.0.

Parce que le struct a un seul champ avec une taille connue, le compilateur applique une optimisation qui n’en est en réalité pas une du tout, c’est juste comment les structs fonctionnent. Le layout mémoire de UserId est identique à u64. Même taille, même alignement, même ABI.

Vous pouvez le vérifier vous-même :

use std::mem;

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

Il n’y a pas de vtable, pas de discriminant, pas d’objet wrapper. Le code machine généré pour passer un UserId dans une fonction est identique à celui pour passer un u64. Le système de types impose la distinction. Le runtime l’efface.

Un exemple concret : mélanger pixels et points

Voici un pattern qui m’a mordu une fois. Une bibliothèque graphique avait des distances en pixels et en points indépendants du périphérique. Les deux étaient des f32. J’ai passé des points là où des pixels étaient attendus, et mon UI s’est affichée à la moitié de la taille sur les écrans haute DPI. Le compilateur est resté silencieux parce que les deux étaient des f32.

Avec des 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`
}

Le compilateur rejette l’erreur avant que vous n’exécutiez le programme. Les valeurs Points et Pixels utilisent les mêmes quatre octets qu’un f32. La sécurité ne coûte rien au runtime.

Les compromis dont personne ne vous parle

Les newtypes ne sont pas gratuits à écrire. Les méthodes du type enveloppé ne se propagent pas automatiquement. Un u64 a wrapping_add, leading_zeros, des dizaines de méthodes. Un UserId brut n’en a aucune à moins que vous ne les implémentiez vous-même.

Vous avez trois options, et une seule est bonne.

Option 1 : Implémenter Deref. Cela vous donne toutes les méthodes du type interne via l’auto-deref :

use std::ops::Deref;

struct UserId(u64);

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

Cela fonctionne, mais cela va à l’encontre du but recherché. Deref permet la coercion implicite de UserId vers u64, ce qui signifie que vous pouvez passer un UserId partout où un u64 est attendu. Vous perdez la sécurité de type que vous venez d’acheter. Ne faites pas cela pour les newtypes.

Option 2 : Tout implémenter manuellement. C’est fastidieux, mais vous n’exposez que les opérations qui ont du sens pour votre domaine. Pour un type d’identifiant, peut-être n’avez-vous besoin que de Display, Debug, PartialEq, Eq, et 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)
    }
}

Pour un type numérique avec de l’arithmétique, vous implémentez Add, Sub, et ainsi de suite. C’est du boilerplate. Les macros derive standard et les crates comme derive_more aident, mais c’est quand même plus de code qu’un type brut.

Option 3 : Utiliser la valeur interne directement quand vous en avez besoin. C’est ma préférence. Gardez le newtype aux frontières de l’API, déballez avec .0 quand vous avez besoin de la valeur brute, et évitez Deref entièrement. C’est légèrement plus verbeux, mais cela préserve la garantie de sécurité.

Les macros derive qui rendent cela supportable

La bibliothèque standard de Rust vous donne #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] gratuitement. Pour l’arithmétique, vous utilisez std::ops ou une crate comme 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)
    }
}

Le compilateur génère toujours le même code machine que l’addition brute de f64. Le trait Add est aussi une abstraction à coût zéro. Il monomorphise pour inliner l’opération.

Quand les newtypes sont le mauvais outil

Toute primitive n’a pas besoin d’un newtype. Si une fonction prend un timeout_ms: u64 et qu’il n’est utilisé que localement, l’envelopper ajoute du bruit sans attraper de vrais bugs. Réservez les newtypes pour les valeurs qui traversent les frontières d’API, persistent dans des bases de données, ou représentent des concepts où les mélanger a de vraies conséquences.

La sérialisation devient aussi étrange. Si vous utilisez serde, un UserId(u64) se sérialise par défaut comme une map {"0": 42}, pas comme 42. Vous avez besoin de #[serde(transparent)] pour corriger cela :

use serde::{Serialize, Deserialize};

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

C’est facile à oublier et ennuyeux à déboguer quand votre API JSON attend soudainement des objets au lieu de nombres.

Commencez par vos APIs publiques

Trouvez toute fonction qui prend plusieurs arguments du même type primitif mais avec des significations différentes. Enveloppez ceux qui traversent les frontières de module ou de service. Ne derivez pas Deref. Utilisez #[serde(transparent)] si vous sérialisez.

Si vous avez cargo-expand installé, lancez cargo expand sur un newtype et regardez le code généré. Vous verrez que le struct n’est que la valeur brute avec un nom de type différent. L’affirmation de coût zéro n’est pas du marketing. C’est littéralement ce que le compilateur émet.