Перейти к содержимому

Rust для разработчиков на JavaScript и TypeScript

Если ты пишешь на JavaScript или TypeScript, ты знаешь о Rust больше, чем думаешь. В Rust есть замыкания, которые работают почти как стрелочные функции, async/await, методы итераторов со знакомыми именами вроде map, filter и find, деструктуризация и один консольный инструмент (Cargo), который делает то же, что npm. Если ты пишешь на TypeScript, ты уже привык к компилятору, который ловит ошибки до запуска кода. Компилятор Rust делает то же самое, только строже.

Так что же на самом деле нового? Три вещи. В Rust нет сборщика мусора: у каждого значения один владелец, а компилятор проверяет владение (ownership) и заимствование (borrowing) до запуска программы. В Rust нет undefined и null: значение, которого может не быть, имеет тип Option. И в Rust нет исключений: функция, которая может завершиться неудачей, возвращает Result. Большая часть времени на обучение уйдёт на эти три идеи.

Зачем разработчики на JavaScript учат Rust? Обычно по одной из этих причин: нужен быстрый веб-бэкенд или консольная утилита, которая поставляется одним файлом; нужно ускорить узкое место в браузере с помощью WebAssembly; нужно написать нативный аддон для Node.js; или просто интересно, как устроены инструменты, которыми ты уже пользуешься. SWC, Biome, Oxc, Rolldown, Rspack, Turbopack, Lightning CSS и Deno написаны на Rust.

Эта страница сопоставляет то, что ты знаешь по JavaScript и TypeScript, с Rust, чтобы ты мог потратить время на то, что действительно ново. Если тебе нужно общее сравнение с C++, Go и Python, загляни в урок Rust против C++, Go и Python.

Пользуйся этой таблицей как разговорником. В последнем столбце — ссылка на урок этого сайта, который объясняет сторону Rust.

JavaScript / TypeScript Rust Подробнее
let и const let (менять нельзя) и let mut (можно). Объект в const в JS всё ещё можно менять изнутри; значение в let в Rust — нельзя Переменные
number Размер выбираешь ты: i32, i64, u32, usize, f64, … Целые и дробные числа — разные типы Типы данных
bigint i128 / u128 или крейт num-bigint для чисел любого размера Типы данных
string String (владеющая, растущая строка) и &str (заимствованный взгляд на текст) Строки
boolean bool Логический тип
Шаблонные строки format!("Hi {name}") и println!("Hi {name}") Вывод на экран
any, unknown any нет. Используй обобщения, перечисление или serde_json::Value для JSON неизвестной формы Обобщения, JSON и Serde
interface или type, описывающие форму объекта struct Структуры
interface, описывающий методы trait Трейты
Структурная типизация (одинаковая форма = один тип) Номинальная типизация: две структуры с одинаковыми полями — всё равно разные типы Структуры
Объединения типов и размеченные объединения (discriminated unions) enum, у которого каждый вариант может нести свои данные Перечисления
Строковые литеральные типы вроде "small" или "large" enum с простыми вариантами: enum Size { Small, Large } Перечисления
undefined, null, необязательное свойство x?: T Option<T>: либо Some(value), либо None Option
?. и ?? ? (внутри функции, которая возвращает Option), .map(...), .and_then(...), .unwrap_or(...) Option
Утверждение non-null x! .unwrap() или .expect("причина"), которые останавливают программу, если значение — None Паника
throw, try/catch, отклонённые Promise Result<T, E> + оператор ? Result
class MyError extends Error Своё перечисление ошибок (часто с thiserror); anyhow в приложениях Свои типы ошибок, anyhow и thiserror
class с полями и методами struct для данных + блок impl для методов Структуры
extends (наследование) Наследования нет. Используй композицию (структура внутри структуры) + трейты с методами по умолчанию Трейты, Паттерны проектирования
class Dog implements Animal impl Animal for Dog Трейты
Массив объектов разных классов с общим интерфейсом Vec<Box<dyn Animal>> (трейт-объект) Трейт-объекты
Обобщения вроде T extends HasId Обобщения с ограничениями трейтов: T: HasId Обобщения
Стрелочные функции и колбэки Замыкания, описываемые трейтами Fn, FnMut, FnOnce Замыкания, Fn, FnMut и FnOnce
this Явный параметр self, &self или &mut self. Никаких сюрпризов с тем, куда указывает this Структуры
Деструктуризация const { x, y } = point Образцы: let Point { x, y } = point; (также в match и в параметрах функций) Сопоставление с образцом
Spread { ...user, admin: true } Синтаксис обновления структуры: User { admin: true, ..user } Структуры
switch или цепочки if по typeof / kind match (компилятор проверяет, что ты учёл все случаи) match
=== ==, который сравнивает значения (через трейт PartialEq). Никакого неявного приведения типов, никогда Популярные трейты
for...of for item in &items Цикл for
map, filter, reduce, find, some, every Методы итераторов: map, filter, fold, find, any, all, затем collect Итераторы
Генераторы (function*) и Symbol.iterator Реализуй трейт Iterator Свои итераторы
Promise<T> Future с Output = T. В отличие от Promise, ничего не делает, пока его не дождутся через await Async/Await
async / await async fn и .await плюс среда выполнения вроде tokio, чтобы их запускать Async/Await, Async подробно
Promise.all tokio::join! или join_all из крейта futures Async подробно
setTimeout tokio::time::sleep в асинхронном коде, иначе std::thread::sleep Async/Await
Web Workers, worker_threads Настоящие потоки: std::thread::spawn или rayon для параллельных циклов Многопоточность
ES-модули: import, export Модули: mod, use и pub Модули
JSON.parse / JSON.stringify serde_json с #[derive(Serialize, Deserialize)] JSON и Serde
console.log println!, а для быстрой отладки — dbg!. Для типов с derive Debug используй {:?} Вывод на экран
readonly, as const, Object.freeze Не нужны: всё и так только для чтения, пока не напишешь mut Переменные
Декораторы Атрибуты вроде #[derive(Debug)] и макросы Макросы
Number("abc") даёт NaN "abc".parse::<i32>() возвращает Err, который нужно обработать Приведение типов, Преобразования
Сборщик мусора Владение и заимствование, проверяемые при компиляции Владение, Заимствование
JavaScript Rust Подробнее
Array Vec<T> (все элементы одного типа) Векторы
Массив фиксированной длины или кортеж TS [string, number] [T; N] для фиксированной длины, кортеж (String, i32) для разных типов Массивы, Кортежи
Map или объект в роли словаря (Record<string, T>) HashMap<K, V> HashMap
Словарь с отсортированными ключами BTreeMap<K, V> HashMap
Set HashSet<T> (или BTreeSet<T> для отсортированного порядка) HashSet
Массив в роли очереди (push + shift) VecDeque<T> (push_back / pop_front, обе операции быстрые) VecDeque и BinaryHeap
Массив в роли стека (push + pop) Vec<T> с push / pop Векторы
Uint8Array, Buffer Vec<u8> или срез байтов &[u8] Срезы
WeakMap, WeakRef Weak<T> (из Rc или Arc) Weak и Cow

Одна вещь, за которой стоит следить: Map, Set и обычный объект в JavaScript помнят порядок, в котором ты добавлял элементы. HashMap и HashSet в Rust — нет. Если нужен отсортированный порядок, используй BTreeMap; если нужен порядок вставки, крейт indexmap даёт ровно это.

В Cargo ты почувствуешь себя как дома. Это менеджер пакетов, инструмент сборки, раннер тестов и запуск скриптов в одном, и каждый проект на Rust пользуется им одинаково.

JavaScript Rust Примечания
node + npm (или pnpm, yarn) cargo Собирает, запускает, тестирует, добавляет пакеты, публикует. См. Cargo и крейты
package.json Cargo.toml
package-lock.json Cargo.lock Создаётся автоматически
node_modules/ Папки в каждом проекте нет. Загрузки один раз кешируются в ~/.cargo, результаты сборки попадают в target/
npmjs.com crates.io Документация по API каждого опубликованного крейта лежит на docs.rs
npm install zod cargo add serde
npm init cargo new my_app (cargo new --lib для библиотеки)
npm run dev / build / test cargo run / cargo build / cargo test Добавь --release для оптимизированной сборки; отладочные сборки намного медленнее
"scripts" в package.json Раздела scripts нет. Используй алиасы cargo в .cargo/config.toml или запускалку задач вроде just
npx some-tool cargo install some-tool, затем запусти cargo binstall скачивает готовые исполняемые файлы вместо компиляции
nvm и .nvmrc rustup и файл rust-toolchain.toml См. Инструменты и установка
Рабочие пространства npm / pnpm (монорепозиторий) Рабочее пространство Cargo (workspace) Один Cargo.toml наверху перечисляет крейты-участники
tsc rustc, который cargo вызывает за тебя
tsc --noEmit cargo check Проверяет типы без сборки; намного быстрее
ESLint cargo clippy Сотни полезных проверок, официально
Prettier cargo fmt Один стандартный стиль для всех
nodemon, tsx watch cargo watch или bacon Ставятся через cargo install
Jest / Vitest / Mocha Встроенные #[test] и cargo test (cargo nextest — популярный более быстрый раннер) См. Тестирование
Снапшоты Jest insta См. Тестирование: дополнительно
jest.mock() mockall или написанные вручную фейки, реализующие твой трейт
fast-check proptest Тестирование на основе свойств
c8 / Istanbul (покрытие) cargo llvm-cov
JSDoc / TypeDoc Doc-комментарии /// в Markdown + cargo doc Примеры кода в документации запускаются как тесты. См. Документация
VS Code + сервер TypeScript VS Code + rust-analyzer или JetBrains RustRover rust-analyzer даёт автодополнение, переход к определению и ошибки прямо в коде
npm publish cargo publish

Вот небольшой Cargo.toml, чтобы сравнить его с package.json:

[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Версия вроде "1" означает то же, что "^1" в package.json: любой совместимый релиз 1.x. Фичи (features) — это необязательные части крейта, которые ты включаешь сам; см. Фичи Cargo и cfg.

Библиотеки и фреймворки: «Что использовать вместо…?»

Заголовок раздела «Библиотеки и фреймворки: «Что использовать вместо…?»»

Это первое, что спрашивает любой разработчик на JavaScript: «Мы используем Express и Prisma. А что есть в Rust?» Вот ближайшие аналоги. Как и в npm, на crates.io для большинства задач много вариантов; здесь собраны популярные и хорошо поддерживаемые.

JavaScript Rust Примечания
Express, Fastify, Koa axum (мы делаем такой в проекте веб-API), actix-web, Rocket axum и actix-web широко используются в продакшене
Middleware Express Слои tower и крейт tower-http (CORS, сжатие, трассировка, таймауты) axum использует tower напрямую
fetch, axios reqwest По умолчанию асинхронный, есть необязательный блокирующий клиент. См. Работа с сетью
ws, Socket.IO WebSocket в axum (фича ws), tokio-tungstenite или socketioxide (сервер Socket.IO)
Apollo Server (GraphQL) async-graphql
gRPC (@grpc/grpc-js) tonic (с prost для protobuf)
Генераторы Swagger / OpenAPI utoipa
jsonwebtoken jsonwebtoken То же имя, та же идея
bcrypt argon2 или bcrypt
JavaScript Rust Примечания
Prisma, TypeORM SeaORM (асинхронный, сущности, миграции) или Diesel (запросы проверяются на типы при компиляции) SeaORM ближе всего к Prisma и TypeORM
Knex, pg, postgres.js sqlx: ты пишешь обычный SQL, а макрос query! проверяет его на настоящей базе данных при компиляции Асинхронный, очень популярный, не ORM
better-sqlite3 rusqlite или sqlx с SQLite
Mongoose, драйвер MongoDB mongodb (официальный драйвер) Схем в стиле Mongoose нет; используются структуры serde
ioredis redis

В уроке Базы данных sqlx, Diesel и SeaORM показаны на работающем коде.

JavaScript Rust Примечания
JSON.parse / JSON.stringify serde + serde_json Стандарт; также умеет TOML, YAML и другое. См. JSON и Serde
Zod, Yup Большую часть работы делают типы твоей структуры + serde; validator или garde для правил вроде «не меньше 8 символов» Если JSON не совпадает со структурой, разбор завершается ошибкой
Lodash В основном итераторы стандартной библиотеки; itertools для дополнительных вещей вроде unique, sorted_by и chunk_by
date-fns, Day.js, Luxon chrono или jiff См. Дата и время
dotenv dotenvy См. Окружение и процессы
Commander, yargs clap См. Проект: CLI-утилита на clap
chalk colored или owo-colors
winston, pino tracing (+ tracing-subscriber) или log + env_logger См. Логирование
uuid uuid
EventEmitter Каналы (std::sync::mpsc или tokio::sync::broadcast для многих слушателей) См. Каналы
BullMQ (очереди задач) tokio::spawn для задач внутри процесса; apalis для постоянных очередей задач
Регулярные выражения regex Без lookahead и обратных ссылок, так задумано. См. Регулярные выражения
JavaScript Rust Примечания
Ускорение тяжёлого кода в браузере Компилируй Rust в WebAssembly с помощью wasm-bindgen и wasm-pack См. Rust и WebAssembly и раздел ниже
React, Vue, Svelte Leptos, Yew, Dioxus (все компилируются в WebAssembly) Все до версии 1.0; API ещё меняются
Electron Tauri: твой веб-фронтенд + бэкенд на Rust, приложения намного меньше Tauri 2 стабилен. См. GUI-приложения
Нативные аддоны Node.js (C++ с node-gyp) napi + napi-derive (napi-rs) или neon Публикуешь в npm готовые исполняемые файлы; C++ не нужен
Игры на Phaser, Three.js Bevy, macroquad Bevy ещё до 1.0 и ломает API между релизами. См. Разработка игр

Бок о бок: интерфейс становится структурой

Заголовок раздела «Бок о бок: интерфейс становится структурой»

Вот объект на TypeScript с интерфейсом, функцией в роли метода и копией через spread:

interface User {
name: string;
age: number;
admin: boolean;
}
const greeting = (u: User) => `Привет, я ${u.name} (${u.age})`;
const ada: User = { name: "Ada", age: 36, admin: false };
const promoted = { ...ada, admin: true };
console.log(greeting(ada));
console.log(promoted);

В Rust форма объекта становится структурой, а функция — методом в блоке impl:

#[derive(Debug, Clone)]
struct User {
name: String,
age: u32,
admin: bool,
}
impl User {
// `&self` означает «этот метод читает пользователя, но не меняет его».
fn greeting(&self) -> String {
format!("Привет, я {} ({})", self.name, self.age)
}
}
fn main() {
let ada = User { name: "Ada".to_string(), age: 36, admin: false };
// Как { ...ada, admin: true } в JavaScript.
let promoted = User { admin: true, ..ada.clone() };
println!("{}", ada.greeting());
println!("{:?}", promoted);
}

Вывод:

Привет, я Ada (36)
User { name: "Ada", age: 36, admin: true }
▶ Попробуй сам

Отличий несколько. У каждого поля точный тип: age — это u32 (целое число, которое не может быть отрицательным), а не какой-то общий number. #[derive(Debug)] позволяет вывести всю структуру через {:?}, как console.log(obj). А зачем .clone()? Spread ..ada переместил бы строку name из ada, и тогда ada больше нельзя было бы использовать. Клонирование сохраняет ada целым. Это владение в действии, и ты будешь встречать его часто.

Бок о бок: размеченное объединение становится enum

Заголовок раздела «Бок о бок: размеченное объединение становится enum»

Разработчики на TypeScript любят размеченные объединения: поле kind говорит, какая у тебя форма, а switch сужает тип:

type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2;
case "rect":
return s.width * s.height;
}
}
const shapes: Shape[] = [
{ kind: "circle", radius: 1 },
{ kind: "rect", width: 2, height: 3 },
];
for (const s of shapes) console.log(area(s).toFixed(2));

В Rust это встроено в язык, и это одна из его лучших возможностей. Каждый вариант перечисления (enum) несёт свои данные, а match разбирает их:

enum Shape {
Circle { radius: f64 },
Rect { width: f64, height: f64 },
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rect { width, height } => width * height,
}
}
fn main() {
let shapes = vec![
Shape::Circle { radius: 1.0 },
Shape::Rect { width: 2.0, height: 3.0 },
];
for shape in &shapes {
println!("{:.2}", area(shape));
}
}

Вывод:

3.14
6.00
▶ Попробуй сам

Строка kind не нужна: сам вариант и есть метка. А match должен покрыть все варианты. Если позже добавить Triangle, каждый match, который о нём забыл, станет ошибкой компиляции. Это похоже на трюк с never для проверки полноты в TypeScript, только включено всегда. Подробнее — в уроке Сопоставление с образцом.

В TypeScript необязательные значения появляются как undefined, null или необязательное свойство, и ты справляешься с ними через ?. и ??:

interface User {
name: string;
email?: string;
}
function findUser(id: number): User | undefined {
if (id === 1) return { name: "Ada", email: "[email protected]" };
if (id === 2) return { name: "Grace" };
return undefined;
}
for (const id of [1, 2, 3]) {
console.log(`${id}: ${findUser(id)?.email?.toUpperCase() ?? "нет email"}`);
}

В Rust значение, которого может не быть, имеет тип Option<T>. Это либо Some(value), либо None, и его нельзя использовать как обычное значение, пока ты не обработал случай None:

struct User {
name: String,
email: Option<String>,
}
fn find_user(id: u32) -> Option<User> {
match id {
1 => Some(User { name: "Ada".to_string(), email: Some("[email protected]".to_string()) }),
2 => Some(User { name: "Grace".to_string(), email: None }),
_ => None,
}
}
// Внутри функции, которая возвращает Option, `?` работает как `?.`:
// если значение — None, остановись здесь и верни None.
fn email_domain(id: u32) -> Option<String> {
let email = find_user(id)?.email?;
let at = email.find('@')?;
Some(email[at + 1..].to_string())
}
fn main() {
for id in [1, 2, 3] {
// Как findUser(id)?.email?.toUpperCase() ?? "нет email"
let email = find_user(id)
.and_then(|user| user.email)
.map(|email| email.to_uppercase())
.unwrap_or("нет email".to_string());
println!("{id}: {email}");
}
println!("{:?}", email_domain(1));
println!("{:?}", email_domain(2));
if let Some(user) = find_user(2) {
println!("найден {}", user.name);
}
}

Вывод:

2: нет email
3: нет email
Some("example.com")
None
найден Grace
▶ Попробуй сам

В Rust нет ошибки «cannot read properties of undefined», потому что нет undefined, из которого можно читать. Компилятор просто не даст вызвать .to_uppercase() у Option<String>. И не нужно помнить разницу между null и undefined: есть только None. Вспомогательные методы описаны в уроке Option, а короткие формы — в уроке if let и while let.

В JavaScript функция бросает ошибку, и ты ловишь её где-то выше по стеку вызовов. Сигнатура функции ничего не говорит о том, что она может бросить ошибку:

function parseAge(input) {
const age = Number(input);
if (!Number.isInteger(age)) throw new Error(`'${input}' — не целое число`);
if (age > 150) throw new Error(`${age} — слишком большой возраст`);
return age;
}
function totalAge(a, b) {
return parseAge(a) + parseAge(b);
}
for (const [a, b] of [["36", "41"], ["36", "abc"], ["36", "200"]]) {
try {
console.log(`Итого: ${totalAge(a, b)}`);
} catch (e) {
console.log(`Ошибка: ${e.message}`);
}
}

В Rust функция, которая может завершиться неудачей, возвращает Result: либо Ok(value), либо Err(error). Оператор ? означает «если здесь произошла ошибка, сразу верни её вызывающему». Он делает то же, что всплытие исключения, но это видно в коде:

fn parse_age(input: &str) -> Result<u32, String> {
let age: u32 = input
.parse()
.map_err(|_| format!("'{input}' — не целое число"))?;
if age > 150 {
return Err(format!("{age} — слишком большой возраст"));
}
Ok(age)
}
fn total_age(a: &str, b: &str) -> Result<u32, String> {
// Каждый `?` передаёт ошибку вызывающему, как это сделало бы исключение.
let first = parse_age(a)?;
let second = parse_age(b)?;
Ok(first + second)
}
fn main() {
for (a, b) in [("36", "41"), ("36", "abc"), ("36", "200")] {
match total_age(a, b) {
Ok(total) => println!("Итого: {total}"),
Err(e) => println!("Ошибка: {e}"),
}
}
}

Вывод:

Итого: 77
Ошибка: 'abc' — не целое число
Ошибка: 200 — слишком большой возраст
▶ Попробуй сам

Сигнатура -> Result<u32, String> говорит каждому вызывающему «это может не получиться», и компилятор не даст это проигнорировать. С асинхронным кодом то же самое: там, где в JavaScript был бы отклонённый Promise, асинхронная функция в Rust возвращает Result, и ты пишешь .await?. Для своих типов ошибок смотри урок Свои типы ошибок. В приложениях крейт anyhow даёт что-то близкое к «поймать любую ошибку» (см. anyhow и thiserror).

Бок о бок: методы массивов становятся итераторами

Заголовок раздела «Бок о бок: методы массивов становятся итераторами»

У методов массивов, которыми ты пользуешься каждый день, есть прямые аналоги в Rust:

const orders = [
{ customer: "Ada", total: 120, paid: true },
{ customer: "Grace", total: 80, paid: false },
{ customer: "Linus", total: 200, paid: true },
];
const paidNames = orders.filter((o) => o.paid).map((o) => o.customer);
const revenue = orders.filter((o) => o.paid).reduce((sum, o) => sum + o.total, 0);
const firstBig = orders.find((o) => o.total > 150);
console.log(paidNames.join(", "));
console.log(`Выручка: ${revenue}`);
console.log(`Первый крупный заказ: ${firstBig?.customer ?? "нет"}`);
console.log(`Есть неоплаченные? ${orders.some((o) => !o.paid)}`);

Версия на Rust использует итераторы и замыкания. Синтаксис замыкания — |o| o.paid вместо (o) => o.paid:

struct Order {
customer: String,
total: u32,
paid: bool,
}
fn main() {
let orders = vec![
Order { customer: "Ada".to_string(), total: 120, paid: true },
Order { customer: "Grace".to_string(), total: 80, paid: false },
Order { customer: "Linus".to_string(), total: 200, paid: true },
];
// filter + map, затем collect() превращает результат в Vec.
let paid_names: Vec<&str> = orders
.iter()
.filter(|o| o.paid)
.map(|o| o.customer.as_str())
.collect();
// reduce с начальным значением называется fold.
let revenue = orders
.iter()
.filter(|o| o.paid)
.fold(0, |sum, o| sum + o.total);
// find возвращает Option, потому что совпадения может и не быть.
let first_big = orders.iter().find(|o| o.total > 150);
println!("{}", paid_names.join(", "));
println!("Выручка: {revenue}");
match first_big {
Some(o) => println!("Первый крупный заказ: {}", o.customer),
None => println!("Первый крупный заказ: нет"),
}
// some() называется any().
println!("Есть неоплаченные? {}", orders.iter().any(|o| !o.paid));
}

Вывод:

Ada, Linus
Выручка: 320
Первый крупный заказ: Linus
Есть неоплаченные? true
▶ Попробуй сам

Есть одно большое отличие от JavaScript. В JS каждый метод массива сразу строит новый массив целиком. Итераторы в Rust ленивые: filter и map сами по себе ничего не делают, а вся работа выполняется за один проход, когда ты вызываешь что-то вроде collect(), sum(), fold() или запускаешь цикл for. Никаких промежуточных массивов не создаётся. Для одной только суммы можно было бы написать и .map(|o| o.total).sum::<u32>(). Сортировка живёт у Vec (sort, sort_by), а не у итераторов; см. Сортировка и поиск.

Вот немного асинхронного JavaScript. Следи, когда выводится "загружаем 1":

async function fetchUser(id) {
console.log(`загружаем ${id}`);
await new Promise((resolve) => setTimeout(resolve, 100));
return `пользователь ${id}`;
}
const promise = fetchUser(1); // сразу выводит "загружаем 1"
console.log("promise создан");
console.log(await promise);
const [a, b] = await Promise.all([fetchUser(2), fetchUser(3)]);
console.log(`${a}, ${b}`);

В JavaScript вызов асинхронной функции сразу её запускает. В Rust всё иначе: вызов async fn только создаёт future — описание работы. Ничего не выполняется, пока ты не сделаешь .await. А встроенного цикла событий в Rust нет, поэтому ты добавляешь среду выполнения: tokio (на английском) — самая популярная.

use std::time::Duration;
async fn fetch_user(id: u32) -> String {
println!("загружаем {id}");
tokio::time::sleep(Duration::from_millis(100)).await;
format!("пользователь {id}")
}
#[tokio::main]
async fn main() {
// Создание future НЕ запускает его. Пока ничего не выведено.
let future = fetch_user(1);
println!("future создан");
println!("{}", future.await);
// Как Promise.all: запустить оба одновременно и дождаться обоих.
let (a, b) = tokio::join!(fetch_user(2), fetch_user(3));
println!("{a}, {b}");
}

Вывод:

future создан
загружаем 1
пользователь 1
загружаем 2
загружаем 3
пользователь 2, пользователь 3
▶ Попробуй сам

Видишь порядок? В JavaScript «загружаем 1» появилось бы до «promise создан». В Rust оно идёт после «future создан», потому что future запускается только на .await. #[tokio::main] настраивает среду выполнения (твой цикл событий) и запускает в ней main. tokio::join! ждёт оба future вместе, как Promise.all; tokio::spawn запускает задачу в фоне — это ближе всего к вызову асинхронной функции без await. Подробности — в уроках Async/Await и Async подробно.

Rust в твоём JavaScript-проекте: WebAssembly и аддоны для Node

Заголовок раздела «Rust в твоём JavaScript-проекте: WebAssembly и аддоны для Node»

Чтобы начать пользоваться Rust, не нужно ничего переписывать. Вот два распространённых способа подмешать его в JavaScript-проект.

WebAssembly в браузере. Пометь функции на Rust атрибутом #[wasm_bindgen], собери через wasm-pack и импортируй результат, как любой ES-модуль. wasm-pack даже пишет файл .d.ts, так что TypeScript знает типы твоих функций:

use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn slugify(title: &str) -> String {
title
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join("-")
}
import init, { slugify } from "./pkg/my_lib.js";
await init(); // скачать и запустить файл .wasm
console.log(slugify("Hello Rust World")); // "hello-rust-world"

Урок Rust и WebAssembly проведёт тебя через всю настройку и объяснит, какие значения дёшево передаются через границу. Совет: Wasm окупается на тяжёлых вычислениях (обработка изображений, разбор данных, симуляции), а не на маленьких функциях, которые вызываются тысячи раз, потому что каждый вызов между JS и Wasm немного стоит.

Нативные аддоны для Node.js. С napi-rs (на английском; крейты napi и napi-derive) ты помечаешь функцию на Rust атрибутом #[napi], и она становится функцией, которую можно получить через require или import в Node. Многие популярные npm-пакеты так и поставляют готовые исполняемые файлы на Rust, поэтому пользователям никогда не нужен компилятор Rust.

Все, кто приходит из JavaScript, натыкаются на одни и те же кочки. Знать их заранее полезно.

Передача значения в функцию перемещает его. В JavaScript при передаче массива в функцию передаётся ссылка, и массивом можно пользоваться дальше. В Rust передача Vec или String по значению перемещает (move) владение в функцию, и исходную переменную больше нельзя использовать:

fn print_all(names: Vec<String>) {
for name in names {
println!("{name}");
}
}
fn main() {
let names = vec![String::from("Ada"), String::from("Grace")];
print_all(names);
println!("{} names", names.len());
}
error[E0382]: borrow of moved value: `names`
--> src/main.rs:10:26
|
8 | let names = vec![String::from("Ada"), String::from("Grace")];
| ----- move occurs because `names` has type `Vec<String>`, which does not implement the `Copy` trait
9 | print_all(names);
| ----- value moved here
10 | println!("{} names", names.len());
| ^^^^^ value borrowed here after move

Компилятор говорит, что значение names уже перемещено в print_all. Обычное исправление — заимствовать: пусть функция принимает names: &[String], а вызов будет print_all(&names). Сначала прочитай уроки Владение и Заимствование: они объясняют это правило, а встречаться с ним ты будешь каждый день.

У чисел есть типы, и они не смешиваются. В JavaScript один number. В Rust 3 — целое число, а 2.5 — дробное, и без преобразования их не объединить:

fn main() {
let items = 3;
let price = 2.5;
let total = items * price;
println!("{total}");
}
error[E0277]: cannot multiply `{integer}` by `{float}`
--> src/main.rs:4:23
|
4 | let total = items * price;
| ^ no implementation for `{integer} * {float}`

Компилятор говорит, что не умеет умножать целое число на дробное. Пиши items as f64 * price или сразу сделай items дробным (3.0). И учти, что целочисленное деление отбрасывает остаток: 7 / 2 равно 3, а не 3.5. См. Типы данных и Приведение типов.

Ещё несколько кочек, на которые ты наткнёшься:

  • Future ленивые, и им нужна среда выполнения. Как ты видел выше, ничего не происходит до .await, а чтобы вообще запустить асинхронный код, нужен tokio (или другая среда выполнения). Если забыть .await, получишь предупреждение компилятора, а не запущенную задачу.
  • Строки нельзя индексировать. name[0] не скомпилируется, потому что String в Rust хранится в UTF-8, и один символ может занимать несколько байтов. Используй name.chars().next() или .chars().nth(i). И пораньше разберись в разнице между String и &str: функции обычно принимают &str, структуры обычно хранят String. См. Строки.
  • Нет структурной типизации и нет any. В TypeScript любой объект с нужными полями подходит под интерфейс. В Rust функция, которая принимает User, принимает только User. Для общего поведения используй трейты. Для JSON неизвестной формы — serde_json::Value.
  • Общие изменяемые графы объектов даются тяжело. В JavaScript нормально передавать один объект повсюду и разрешать всем его менять или делать объекты, которые ссылаются обратно на родителя. Анализатор заимствований (borrow checker) разрешает одновременно много читателей или одного писателя, но никогда и то и другое. Такие схемы требуют умных указателей (Rc<RefCell<T>>, Arc<Mutex<T>>) или, что лучше, другой архитектуры. Приготовься к нескольким неделям раздражения. Потом станет намного легче.
  • У HashMap нет порядка. Map и объекты в JavaScript сохраняют порядок вставки, и код часто незаметно на это полагается. HashMap в Rust — нет. Сортируй перед выводом или используй BTreeMap либо крейт indexmap.
  • Нет исключений. Ошибки — это значения, и ты будешь часто писать ?. Вызывать .unwrap() во время обучения нормально, но это версия Rust для ! из TypeScript: «поверь мне, значение там есть». Если ты ошибся, программа паникует.
  • Время компиляции. Мгновенной перезагрузки, как у node app.js, нет. Первая сборка с множеством крейтов занимает время; следующие сборки инкрементальные и быстрее. Пока редактируешь, используй cargo check: он пропускает генерацию кода.

Этот сайт не обязательно читать по порядку. Для разработчика на JavaScript или TypeScript этот маршрут пропускает то, что ты уже знаешь, и тратит время там, где Rust отличается:

  1. Первые шаги и Инструменты и установка: установи Rust, настрой VS Code с rust-analyzer, запусти cargo new.
  2. Переменные и типы данных: прочитай их как следует. Числовые типы для тебя новые.
  3. Владение, затем заимствование, затем срезы: самые важные для тебя страницы. Не торопись.
  4. Строки: String и &str.
  5. Структуры и перечисления, затем match и сопоставление с образцом: твои интерфейсы и размеченные объединения.
  6. Option и Result, затем свои типы ошибок.
  7. Замыкания и итераторы: твои методы массивов.
  8. Векторы, HashMap и HashSet.
  9. Трейты, обобщения и популярные трейты.
  10. Модули, Cargo и крейты и тестирование.
  11. Времена жизни и умные указатели.
  12. Async/Await и Async подробно.
  13. JSON и Serde, а потом собери проект веб-API или попробуй WebAssembly.

В обоих упражнениях дан фрагмент на JavaScript или TypeScript. Перенеси его на Rust.

Этот код на JavaScript считает, сколько раз встречается каждое слово, а затем выводит слова от самых частых к самым редким (при равенстве — по алфавиту). Перенеси его на Rust. Подсказка: используй HashMap<&str, u32> и *counts.entry(word).or_insert(0) += 1. Чтобы отсортировать, собери словарь в Vec пар (слово, количество) и используй sort_by.

const text = "the cat and the hat and the bat";
const counts = new Map();
for (const word of text.split(" ")) {
counts.set(word, (counts.get(word) ?? 0) + 1);
}
const sorted = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
for (const [word, n] of sorted) console.log(`${word}: ${n}`);
Показать решение
use std::collections::HashMap;
fn main() {
let text = "the cat and the hat and the bat";
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split(' ') {
*counts.entry(word).or_insert(0) += 1;
}
// Как [...counts]: превращаем словарь в список пар (слово, количество).
let mut sorted: Vec<(&str, u32)> = counts.into_iter().collect();
// Сначала самые частые; при равенстве — по алфавиту.
sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (word, n) in sorted {
println!("{word}: {n}");
}
}

Вывод:

the: 3
and: 2
bat: 1
cat: 1
hat: 1
▶ Попробуй сам

entry(word).or_insert(0) за один шаг делает и часть counts.get(word) ?? 0, и set: он даёт изменяемую ссылку на счётчик и создаёт его со значением 0, если слова ещё нет. Сортировка здесь обязательна, а не просто приятна: без неё HashMap выводит элементы в разном порядке от запуска к запуску. Слова в примере оставлены английскими, чтобы сортировка по алфавиту давала тот же результат, что и в оригинале.

2. Перенеси необязательные настройки со значениями по умолчанию

Заголовок раздела «2. Перенеси необязательные настройки со значениями по умолчанию»

Эта функция на TypeScript принимает объект настроек, где каждое поле необязательно, и заполняет значения по умолчанию через ??. Перенеси её на Rust. Подсказка: сделай структуру с полями Option и добавь #[derive(Default)]. Тогда ServerOptions { port: Some(3000), ..Default::default() } работает как передача { port: 3000 }. Для bool значение по умолчанию — false.

interface ServerOptions {
host?: string;
port?: number;
verbose?: boolean;
}
function describe(opts: ServerOptions): string {
const host = opts.host ?? "localhost";
const port = opts.port ?? 8080;
const mode = opts.verbose ? "подробно" : "тихо";
return `${host}:${port} (${mode})`;
}
console.log(describe({}));
console.log(describe({ port: 3000 }));
console.log(describe({ host: "example.com", verbose: true }));
Показать решение
#[derive(Default)]
struct ServerOptions {
host: Option<String>,
port: Option<u16>,
verbose: bool,
}
fn describe(opts: &ServerOptions) -> String {
let host = opts.host.as_deref().unwrap_or("localhost");
let port = opts.port.unwrap_or(8080);
let mode = if opts.verbose { "подробно" } else { "тихо" };
format!("{host}:{port} ({mode})")
}
fn main() {
println!("{}", describe(&ServerOptions::default()));
println!("{}", describe(&ServerOptions { port: Some(3000), ..Default::default() }));
println!(
"{}",
describe(&ServerOptions {
host: Some("example.com".to_string()),
verbose: true,
..Default::default()
})
);
}

Вывод:

localhost:8080 (тихо)
localhost:3000 (тихо)
example.com:8080 (подробно)
▶ Попробуй сам

unwrap_or — это твой ??. as_deref() превращает Option<String> в Option<&str>, чтобы можно было подставить обычный текст "localhost" без всякого копирования. А ..Default::default() — это способ Rust сказать «каждое поле, которое я не упомянул, получает значение по умолчанию», то есть ровно то, что в JavaScript делает пропущенное свойство. А u16 для порта означает, что порт вроде 70000 даже нельзя случайно сохранить.

  • Многое из JavaScript и TypeScript переносится: замыкания, async/await, деструктуризация и методы в стиле массивов вроде map, filter и find — у всего этого есть близкие аналоги в Rust.
  • Главные сдвиги: владение вместо сборщика мусора, Option вместо undefined и null и Result + ? вместо throw и try/catch.
  • Интерфейсы TypeScript становятся структурами (для данных) или трейтами (для поведения), а размеченные объединения — перечислениями с match.
  • Future в Rust ленивые, и им нужна среда выполнения вроде tokio; роль Promise.all играет tokio::join!.
  • Cargo заменяет npm, package.json и твои скрипты; clippy и rustfmt заменяют ESLint и Prettier; тестирование встроено.
  • Вместо Express смотри на axum или actix-web, вместо fetch — на reqwest, вместо Prisma — на SeaORM или Diesel, вместо Electron — на Tauri. А ещё Rust можно принести в JS-проект через WebAssembly или napi-rs.

Проверь себя

1. Что Rust использует вместо undefined и null?

2. В JavaScript вызов асинхронной функции сразу её запускает. Что происходит, если вызвать async fn в Rust без .await?

3. В твоём коде на TypeScript есть размеченное объединение с полем kind. Какая возможность Rust ближе всего?

4. Бэкенд твоей команды написан на Express. Какой крейт Rust ближе всего?

Результат: 0 / 4

  • Язык программирования Rust: официальная книга (перевод на русский). Для разработчиков на JavaScript важнее всего главы 4 (владение), 6 (перечисления и сопоставление с образцом) и 9 (ошибки).
  • Tokio tutorial (на английском): официальное руководство по асинхронному Rust с tokio — от первой задачи до каналов и ввода-вывода.
  • The wasm-bindgen Guide (на английском): как Rust и JavaScript общаются друг с другом в WebAssembly, с множеством примеров.
  • Compiling from Rust to WebAssembly (MDN) (на английском): пошаговое руководство из MDN Web Docs, включая публикацию в npm.
  • napi-rs (на английском): документация по написанию аддонов для Node.js на Rust.