@alex6357/rustyjs
v1.0.0
Published
Introduce Rust traits to JavaScript
Maintainers
Readme
RustyJS
Write JavaScript with Rust's mental model. No prototype pollution, no gc overhead, no hidden classes.
Usage
Setup
import { initRustyJs } from "@alex6357/rustyjs";
// By default, rustyjs uses a symbol to store state.
// This will assign a property to the class.
// The symbol cannot be changed or enumerated, but can be get by `Object.getOwnPropertySymbols`.
// If you want to make internal state completely private, initialize with `initRustyJs("weakmap")`.
// This will setup a WeakMap to store state. It's less efficient, but more secure.
const { STATE, ImplFor } = initRustyJs();Normal usage
import { initRustyJs } from "@alex6357/rustyjs";
const { STATE, ImplFor } = initRustyJs();
// Traits
interface TraitCanBark {
bark(): void;
}
interface TraitCanEat {
eat(): void;
}
interface TraitCanCharge {
charge(): void;
}
interface TraitCanBarkAfterEat {
eatAndBark(): void;
}
interface TraitCanSpeakName {
speakName(): void;
}
interface TraitHasName {
// Can also define properties.
readonly name: string;
}
// Structs, or rather, State
interface StateDog {
name: string;
}
interface StateRobotDog {
name: string;
battery: number;
}
// Class is still entry point.
// Interface is used to let language server know what methods are available.
interface Dog extends TraitCanBark, TraitCanEat, TraitCanBarkAfterEat, TraitHasName {}
class Dog {
constructor(name: string) {
const state = STATE.init(this, { name } as StateDog);
}
}
interface RobotDog extends TraitCanBark, TraitCanCharge, TraitCanSpeakName, TraitHasName {}
class RobotDog {
constructor(name: string, battery: number) {
const state = STATE.init(this, { name, battery } as StateRobotDog);
}
}
// Implementations
// We use decorator to make it clearer.
// The name of the classes are not important. But they must be unique in the scope.
// You can also manually call the decorator function, for example:
// let implForDogAndRobotDog = ImplFor(Dog, RobotDog);
// implForDogAndRobotDog(ImplCanBarkForDogAndRobotDog);
// Or simply ImplFor(Dog, RobotDog)(ImplCanBarkForDogAndRobotDog);
// The `implForDogAndRobotDog` function can be reused.
@ImplFor(Dog, RobotDog)
class ImplCanBarkForDogAndRobotDog implements TraitCanBark {
bark() {
console.log("Woof!");
}
}
@ImplFor(Dog)
class ImplCanEatForDog implements TraitCanEat {
eat() {
console.log("Nom nom nom");
}
}
@ImplFor(RobotDog)
class ImplCanChargeForRobotDog implements TraitCanCharge {
charge(): void {
console.log("Charging...");
}
}
// Blanket implementations
// Similar to Rust's `impl<T: CanEat + CanBark> CanBarkAfterEat for T`.
// You still need to specify all target classes in @ImplFor.
@ImplFor(Dog)
class ImplCanBarkAfterEat implements TraitCanBarkAfterEat {
eatAndBark(this: TraitCanEat & TraitCanBark): void {
this.eat();
this.bark();
}
}
// When writing a blanket implementation that relies on data (like `TraitCanSpeakName`), you have two architectural choices:
// Approach 1: Using State Slots (data-driven & faster)
// Fetch the internal state directly inside the implementation function itself. Similar to Ruby's mixins requiring target members.
// Best for scenarios where you need to access a large amount of diverse internal data.
// It is highly recommended to keep your state data structure flat for memory access speed, unless a fixed nested structure provides specific semantic value.
// Note: If you worry about property naming conflicts across mixins, use a unique symbol:
// const kName: unique symbol = Symbol("TraitSlotCanSpeakName.name");
interface TraitSlotCanSpeakName {
name: string; // Requires the target's internal STATE to have a `name` member variable
}
@ImplFor(RobotDog) // You still need to specify all the target classes.
class ImplCanSpeakName implements TraitCanSpeakName {
speakName(this: any): void {
// Get internal state first. This has a tiny performance cost, so cache it in a local variable.
const state = STATE.get(this) as TraitSlotCanSpeakName;
console.log(`My name is ${state.name}`);
// console.log(`My name is ${state[kName]}`);
}
}
// Approach 2: Using Trait Constraints / Properties
// State is fetched via another Trait's getter. You constrain `this` to that Trait. Similar to Rust's `impl<T: TraitHasName> TraitCanSpeakName for T`.
// Best for scenarios where you only need a specific category of data. It provides a clear, highly decoupled code structure.
// Accessing `this.name` triggers the getter, which calls `STATE.get(this)` again under the hood, resulting in an extra function call overhead. If multiple data is needed, `STATE.get(this)` will be called multiple times, leading to a performance decrease.
@ImplFor(Dog, RobotDog)
class ImplHasNameForDogAndRobotDog implements TraitHasName {
// Exposing internal state via a Trait property (getter).
// You can think of this as a public getter on the class itself, rather than the internal State.
get name() {
return (STATE.get(this) as StateDog | StateRobotDog).name;
}
}
/*
// Example of Approach 2 (Blanket Impl relying on TraitHasName):
@ImplFor(RobotDog)
class ImplCanSpeakName implements TraitCanSpeakName {
// Require `this` to implement `TraitHasName`
speakName(this: TraitHasName) {
// Structurally very clear, but triggers the `get name()` function overhead
console.log(`My name is ${this.name}`);
}
}
*/
// Now we can use them
const dog = new Dog("Buddy");
console.log("Dog barking");
dog.bark();
console.log("\nDog eating");
dog.eat();
console.log("\nDog eating and barking");
dog.eatAndBark();
console.log("\nDog's name");
console.log(dog.name);
const robotDog = new RobotDog("R2-D2", 100);
console.log("\nRobot dog barking");
robotDog.bark();
console.log("\nRobot dog eating");
robotDog.charge();
console.log("\nRobot dog speaking name");
robotDog.speakName();
console.log("\nRobot dog's name");
console.log(robotDog.name);If you need inheritance
import { initRustyJs } from "@alex6357/rustyjs";
const { STATE, ImplFor } = initRustyJs();
// States
interface StateEventTarget {
listeners: Map<string, Function[]>;
}
// inherit father's state
interface StateNode extends StateEventTarget {
nodeName: string;
childNodes: any[];
}
// Traits, also type announcements
// Methods writen in Traits are automatically inherited by subclasses
// If you don't want a method to be inherited, write it in class, not in Traits.
interface EventTarget {
addEventListener(type: string, cb: Function): void;
}
interface Node extends EventTarget {
appendChild(child: any): void;
}
class EventTarget {
constructor() {
// State initialized by base class
STATE.init(this, {
listeners: new Map(),
} as StateEventTarget);
}
}
class Node extends EventTarget {
constructor(nodeName: string) {
super(); // State already initialized by base class
// Get state rather than creating a new one
const state = STATE.get(this) as StateNode;
// Set state used in subclass
state.nodeName = nodeName;
state.childNodes = [];
}
}
// Implementations
@ImplFor(EventTarget)
// This is EventTarget's implementation block, i.e. impl EventTarget
class ImplEventTarget {
addEventListener(this: EventTarget, type: string, cb: Function) {
const state = STATE.get(this) as StateEventTarget;
if (!state.listeners.has(type)) state.listeners.set(type, []);
state.listeners.get(type)!.push(cb);
console.log(`[EventTarget] Added listener ${type}`);
}
}
@ImplFor(Node)
class ImplNode {
appendChild(this: Node, child: any) {
const state = STATE.get(this) as StateNode;
state.childNodes.push(child);
console.log(`[Node] ${state.nodeName} appended a child`);
}
// If you need a method from a superclass, you can't use super.methodName, because
// in the implementation class, super is `object`, not actual superclass.
// You need to call method directly from superclass's prototype
// For example: Node.prototype.addEventListener.call(this, type, cb);
// This also means superclass's implementation block must be before subclass's
}
const div = new Node("DIV");
console.log(div instanceof Node);
console.log(div instanceof EventTarget);
div.addEventListener("click", () => {}); // Inherited from EventTarget
div.appendChild(new Node("SPAN"));Extend foreign classes
const { ImplFor } = initRustyJs();
interface TraitFormat {
format(): string;
}
// Use declare global to mixin extension for global types
// If you are extending a not-builtin type, an interface should be enough, for example:
// interface ForeignClass extends YourTrait {}
declare global {
interface Date extends TraitFormat {}
}
@ImplFor(Date)
class ImplFormatForDate implements TraitFormat {
format(this: Date) {
return `${this.getFullYear()}-${this.getMonth() + 1}`;
}
}
const now = new Date();
console.log(now.format());Build and test
# Build
pnpm build
# Build test (need to build first)
pnpm build:test
# Test
pnpm testHow this works?
Core of RustyJS is a decorator.
function ImplFor(...targets: any[]) {
return function (implClass: any) {
const descriptors = Object.getOwnPropertyDescriptors(implClass.prototype);
// @ts-ignore
delete descriptors.constructor;
for (const target of targets) {
Object.defineProperties(target.prototype, descriptors);
}
};
}This decorator will directly assign all properties (except for constructor) of implClass.prototype to all target prototypes.
Unlike traditional Mixins or Class Factories that create deeply nested prototype chains, RustyJS operates at load-time. It extracts all methods from your Impl class and stamps them directly onto the target class prototype. No prototype chain crawling.
To mimic Rust's strict separation of Struct (Data) and Impl (Behavior), RustyJS forces you to keep instance data out of the this context directly. Instead, you manage data through a unified STATE accessor.
let accessor: StateAccessor<any>;
if (config?.mode === "weakmap") {
const map = new WeakMap<object, any>();
accessor = {
get: (obj) => {
const state = map.get(obj);
if (!state) throw new Error("State uninitialized!");
return state;
},
init: (obj, state) => {
if (map.has(obj)) throw new Error("State already initialized!");
map.set(obj, state);
return state;
},
};
} else {
const State = Symbol("rustyJsState");
accessor = {
get: (obj) => {
const state = obj[State];
if (!state) throw new Error("State uninitialized!");
return state;
},
init: (obj, state) => {
if (obj[State]) throw new Error("State already initialized!");
Object.defineProperty(obj, State, {
value: state,
enumerable: false,
writable: false,
configurable: false,
});
return state;
},
};
}The accessor supports two modes:
Symbol (Performance First, Default)
When configured to "symbol" mode, RustyJS creates a unique Symbol captured within the closure.
When you call STATE.init, it uses Object.defineProperty to attach your state object directly to the instance using this Symbol. It is explicitly set as non-enumerable, non-writable, and non-configurable.
It's more performant. But you can still get the symbol by using Object.getOwnPropertySymbols and access internal state.
WeakMap (Absolute Privacy)
When configured to "weakmap" mode, RustyJS creates a WeakMap captured within the closure.
When you call STATE.init, the instance itself is used as a reference key, and the data is stored in the map.
It's true absolute privacy. It is physically impossible for external code to get the state. Automatic garbage collection is handled by the JS engine when the instance is destroyed.
Comparison
Environment: Windows x86_64, 10,000,000 iterations.
Compare Initialization (Init) and High-Frequency Access (Get) performance between Symbol mode and WeakMap mode across different JavaScript runtimes. See tests/test-speed.js for details.
| Runtime | Engine | Symbol Init | WeakMap Init | Init Winner | Symbol Get | WeakMap Get | Get Winner | | :-------------------- | :------------- | :---------- | :----------- | :------------------ | :----------- | :---------- | :------------------ | | Bun 1.3.9 | JavaScriptCore | 673 ms | 1,072 ms | Symbol (1.59x) | 11.72 ms | 73.57 ms | Symbol (6.28x) | | Node.js 24.13.0 | V8 | 1,032 ms | 6,543 ms | Symbol (6.34x) | 6.70 ms | 59.45 ms | Symbol (8.87x) | | Deno 2.7.1 | V8 | 1,877 ms | 3,710 ms | Symbol (1.98x) | 4.88 ms | 56.90 ms | Symbol (11.66x) | | QuickJS NG 0.11.0 | QuickJS | 8,838 ms | 5,299 ms | WeakMap (1.67x) | 1,852 ms | 2,046 ms | Symbol (1.10x) | | Boa | Boa (Rust) | 17,309 ms | 10,377 ms | WeakMap (1.67x) | 6,014 ms | 5,980 ms | Tie |
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
