Передайте u64 с идентификатором пользователя в функцию, которая ожидает идентификатор заказа, и Rust не пожалуется. Оба типа — u64. Компилятор видит одинаковые типы, поэтому не может вам помочь. Вы узнаёте об этом во время выполнения, обычно в продакшене, обычно после рефакторинга, который вы считали безопасным.

Именно этот класс багов newtypes и существуют, чтобы устранить.

Newtype — это кортежная структура с одним полем, которая оборачивает существующий тип: struct UserId(u64);. На этапе компиляции UserId и OrderId несовместимы. Во время выполнения они занимают ровно столько же байт, что и голый u64. Никаких дополнительных аллокаций, никакой косвенности, никаких затрат.

Какую проблему это реально решает?

В каждом языке со статической типизацией есть эта проблема. У вас есть два значения с одинаковым представлением, но разным смыслом. Ключи баз данных, физические единицы измерения, суммы валют, проценты против сырых счётчиков. В C вы бы использовали typedef, в Go — псевдоним типа, но это всего лишь имена одного и того же базового типа. Компилятор по-прежнему считает их идентичными.

Паттерн newtype в Rust другой. struct UserId(u64); создаёт отдельный тип. Вы не можете передать UserId туда, где ожидается OrderId. Вы не можете случайно добавить процент к сырому счётчику. Ошибка всплывает на этапе компиляции, а не в продакшен-инциденте.

Это то, что люди подразумевают под «деланием некорректных состояний невыразимыми». Это не теория. Однажды я видел endpoint API, который принимал идентификатор аккаунта и сумму перевода, оба u64. Рефакторинг поменял их местами в вызывающем коде. При компиляции ничего не сломалось. Деньги ушли не туда. С newtypes этот рефакторинг не скомпилировался бы ещё до того, как кто-либо задеплоил его.

Как newtypes работают под капотом

Newtype в Rust — это просто структура с одним безымянным полем:

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

Компилятор считает UserId совершенно отдельным типом от OrderId и от u64. Вы конструируете её явно: let id = UserId(42);. Доступ к внутреннему значению — через id.0.

Поскольку у структуры одно поле с известным размером, компилятор применяет оптимизацию, которая на самом деле вовсе не оптимизация, а просто то, как работают структуры. Раскладка памяти UserId идентична u64. Тот же размер, тот же alignment, тот же ABI.

Вы можете проверить это сами:

use std::mem;

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

Нет vtable, нет дискриминанта, нет обёрточного объекта. Сгенерированный машинный код для передачи UserId в функцию идентичен передаче u64. Система типов обеспечивает различие. Runtime стирает его.

Конкретный пример: путаница пикселей и поинтов

Вот паттерн, который однажды укусил меня. Графическая библиотека имела расстояния и в пикселях, и в device-independent points. Оба были f32. Я передал points туда, где ожидались pixels, и мой UI отрендерился в половинном размере на high-DPI экранах. Компилятор молчал, потому что оба типа были f32.

С 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`
}

Компилятор отклоняет ошибку ещё до запуска программы. Значения Points и Pixels используют те же четыре байта, что и f32. Безопасность не стоит ничего во время выполнения.

Компромиссы, о которых вам никто не расскажет

Newtypes не бесплатны в написании. Методы обёрнутого типа не распространяются автоматически. У u64 есть wrapping_add, leading_zeros, десятки методов. У голого UserId нет ни одного из них, если вы не реализуете их сами.

У вас три варианта, и только один из них хороший.

Вариант 1: Реализовать Deref. Это даёт вам все методы внутреннего типа через auto-deref:

use std::ops::Deref;

struct UserId(u64);

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

Это работает, но сводит на нет всю цель. Deref позволяет неявное приведение от UserId к u64, что означает, что вы можете передать UserId куда угодно, где ожидается u64. Вы теряете типовую безопасность, которую только что купили. Не делайте этого для newtypes.

Вариант 2: Реализовать всё вручную. Это утомительно, но вы экспонируете только те операции, которые имеют смысл для вашего домена. Для типа ID, возможно, вам нужны только Display, Debug, PartialEq, Eq и 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)
    }
}

Для числового типа с арифметикой вы реализуете Add, Sub и так далее. Это boilerplate. Стандартные макросы derive и крейты вроде derive_more помогают, но это всё равно больше кода, чем сырые типы.

Вариант 3: Использовать внутреннее значение напрямую, когда оно нужно. Это мой предпочтительный вариант. Держите newtype на границах API, разворачивайте через .0, когда нужно сырое значение, и полностью избегайте Deref. Это немного многословнее, но сохраняет гарантию безопасности.

Макросы derive, которые делают это терпимым

Стандартная библиотека Rust даёт вам #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] бесплатно. Для арифметики вы обращаетесь к std::ops или крейту вроде 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)
    }
}

Компилятор всё ещё генерирует тот же машинный код, что и для сложения сырых f64. Трейт Add — тоже zero-cost абстракция. Он мономорфизируется для инлайна операции.

Когда newtypes — неправильный инструмент

Не каждому примитиву нужен newtype. Если функция принимает timeout_ms: u64 и используется только локально, оборачивание добавляет шума, не ловя реальных багов. Резервируйте newtypes для значений, которые пересекают границы API, сохраняются в базах данных или представляют концепции, где их путаница имеет реальные последствия.

Сериализация тоже становится странной. Если вы используете serde, UserId(u64) по умолчанию сериализуется как map {"0": 42}, а не как 42. Вам нужен #[serde(transparent)], чтобы это исправить:

use serde::{Serialize, Deserialize};

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

Это легко забыть и раздражающе отлаживать, когда ваш JSON API вдруг начинает ожидать объекты вместо чисел.

Начните со своих публичных API

Найдите любую функцию, которая принимает несколько аргументов одного примитивного типа, но с разным смыслом. Оборачивайте те, которые пересекают границы модулей или сервисов. Не делайте derive Deref. Используйте #[serde(transparent)], если сериализуете.

Если у вас установлен cargo-expand, запустите cargo expand на newtype и посмотрите на сгенерированный код. Вы увидите, что структура — это просто сырое значение с другим именем типа. Утверждение о zero-cost — это не маркетинг. Это буквально то, что компилятор emits.