Rust's std, in TypeScript. Rust-like developer experience first, convenience second.
Status: paused at v0.1.0 The core below works and is tested. See Status for what is unfinished.
const pair = Tuple(new Connection(), new Socket());
drop(pair); // throws before disposing anything: Socket is async-only
await async_drop(pair); // disposes both, in order, and the tuple is consumedJavaScript lets you do almost anything, which is the problem. Rust's std is a set of
decisions about what should be hard to get wrong: Option instead of null, Result instead
of exceptions, one element type per collection, resources that clean up after themselves.
rust-ts takes those decisions seriously. Where TypeScript can enforce a rule at compile time,
it does, and the error says what to do about it. Where it can't, the rule is enforced at runtime
with a panic that reads like rustc's. Where JavaScript makes a Rust behavior impossible, the
difference is documented (see Where it differs from Rust).
| Area | What you get |
|---|---|
Option, Result |
Classes with the Rust API, comparable with structural equality |
Slice, Vec, FixedArray |
Non-copying slice views, the full Vec API, fixed-size arrays |
Tuple |
Heterogeneous, per-position types, destructuring moves values out |
OnceLock |
Set-once cell with Drop support |
| Sized numbers | u8..u128, i8..i128, usize, isize, f32, f64, with compile-time range checks on literals |
| Decimals | d32, d64, d128: exact decimal arithmetic (no Rust equivalent, added on purpose) |
| Traits | trait(), impl(), impls(): nominal traits as mixins, enforced at compile time and runtime |
| Ownership | Drop, AsyncDrop, Container, drop(), async_drop() |
| Formatting | println("{} {:?}", ...) with format strings checked at compile time |
match |
Pattern matching helpers |
| Concurrency | sync (channels, OnceLock, semaphore) and thread |
There is no build step: the package ships raw .ts source, and you opt in through your own
tsconfig.
Without those two flags the package cannot be imported at all, so there is no half-working state.
Tested with Bun (bun test) and checked with tsc directly.
import { vec, FixedArray, Tuple, Option, u8, d64 } from "rust-ts/prelude";
// collections are homogeneous, like Vec<T>
const v = vec<number>();
v.push(1);
vec<number | string>(); // compile error: unions are not allowed, collections must be homogeneous
// need mixed types? use a Tuple. Each position keeps its own type
const row = Tuple("sing", 2); // Tuple<["sing", 2]>
row[0]; // "sing"
row[2]; // panics: index out of bounds: the len is 2 but the index is 2
// sized numbers check literals at compile time
const a: u8 = u8.MAX; // 255
const b: u8 = 256; // compile error
// exact decimals, built from strings, no floating point surprises
d64("0.10").add(d64("0.20")).eq(d64("0.30")); // trueRust drops values automatically at the end of a scope. JavaScript has no such moment, so
rust-ts makes the rules explicit and checks them.
class Connection extends impl(Drop) {
[Symbol.dispose]() { /* close it */ }
}
class Socket extends impl(AsyncDrop) {
async [Symbol.asyncDispose]() { /* close it */ }
}
drop(new Connection()); // calls [Symbol.dispose]()
await async_drop(new Socket()); // calls [Symbol.asyncDispose]()
drop(new Socket()); // compile error, and a panic at runtime: use async_dropDropping consumes the value: any later use panics with use of dropped value.
A type that owns other values implements Container. Vec, FixedArray, Tuple and OnceLock
all do. In Rust this is automatic ("drop glue"); in JavaScript the marker says "my dispose
forwards to the values I own". It is also the only way to implement both Drop and AsyncDrop:
a plain class with both is a compile error, because drop could not tell which path to take.
| Situation | Result |
|---|---|
Value implements Drop |
drop(x) disposes it |
| Value is async-only | drop(x) is a type error and throws; use async_drop |
| Plain class with both traits | type error: remove one trait, or make it a Container |
| Container holding an async-only element | drop refuses before disposing anything; the container stays intact, so async_drop still works |
| An element's dispose throws | the rest are still dropped, the container is consumed, the error is rethrown (an AggregateError if several threw) |
| Dropping twice | panics: cannot drop a dropped value |
Destructuring is the move. Each slot is emptied as it is taken, and the tuple's drop skips emptied slots, so a value you took and dropped yourself is never disposed twice.
const pair = Tuple(new Connection(), new Connection());
const [first] = pair; // slot 0 is now moved out
drop(first); // disposes the first connection
drop(pair); // disposes only the second one
pair[0]; // panics: use of moved value: Tuple.0
pair.clone(); // panics: use of partially moved valueIndex reads (pair[1]) never move. TypeScript cannot track moves, so this is enforced at
runtime only.
- Homogeneous collections.
Vec,FixedArrayandSlicereject union element types at compile time.Option<A>is fine;Option<A | B>is not. Mixed values go in aTuple. - No promises in
OnceLock. Rejected by the type, and again at runtime insetandget_or_init. A promise cannot be cancelled or dropped, so storing one would silently leak whatever it resolves to. Await it and store the resolved value. - Traits are enforced.
impl(Drop)makes[Symbol.dispose]required, so forgetting it is a compile error.impls(x, Drop)is a nominal check at runtime. - Decimals track their precision. Scale and significant digits live in the type, so a
multhat provably overflows is a compile-time error. - Format strings are parsed by the type system.
println("{} {:?}", a, b)checks each placeholder against its argument at compile time. - Stale slice views are caught. A
Slicetaken from aVecthrowsslice invalidatedafter theVecchanges structurally. Rust's borrow checker does this at compile time; this is the runtime equivalent.
These are deliberate, and most of them follow from JavaScript.
- No scope-end drop. You call
drop()/async_drop()yourself.const [, rx] = pairtakes slot 0 and discards it without disposing it, so bind every droppable value and drop the ones you do not want. - Moves are runtime-only, and only
Tupledestructuring moves. A tuple of plain numbers can be destructured once, where Rust's all-Copytuples can be reused. - A rest pattern gives an array.
const [a, ...rest] = tupleproduces a plain array, not aTuple. FixedArrayhas noDefault.Nexists only in the type system and is erased at runtime, so there is no length to allocate. This is a hard limit, not a missing feature.Tupleis read-only. Rust tuples are mutable behindmut.- Panics are thrown
Errors with Rust-style messages. - Decimals,
shared_json_buffer,transferableandserializableare additions. Rust has no equivalent (floats, and passing data between workers, are not problems Rust has in the same form).
The layout mirrors Rust's std paths where one exists, and invents sensibly where one doesn't.
stdmatches:option,result,vec,slice,array,range,cmp,clone,default,ops,mem,sync,thread,fmtprimitive: re-exports the sized numbers, matchingstd::primitivelang:matchand the trait machinery. These are Rust syntax, not library items, so there is nostd::match. Modeled on Java'sjava.langmacros:println, as instd::macrospreludeis plain re-exports with no side effects.globalsis the separate opt-in for zero-import ambient globals- Internal helpers and
*_implfiles are never exported, so they cannot be imported by accident
Works and is tested: Option, Result, Slice, Vec, FixedArray, Tuple, OnceLock,
sized numbers, decimals, the trait system, Drop / AsyncDrop / Container, drop /
async_drop, println.
Not done:
Displaytrait:{}inprintlnonly accepts primitives until it exists- Channel semantics:
Receiver.recv()is async (tokio-style), wherestd's blocks the thread.try_recv,recv_timeoutand a blocking iterator are missing. The plan is a std-faithful blockingrecvwith an async variant and a main-thread guard Tuple'sCloneis unconditional; in Rust it exists only when every element isClone- A generator for the package
exportsmap, so internal files stay unreachable
bun test # run the tests
bunx tsc --noEmit # type-checkTrait classes need a declare static readonly __instance brand so that generic traits keep
their type argument (see the docs on trait).
The trait system is inspired by traits-ts/core. Everything else follows the Rust standard library documentation.
MIT