Skip to content

Instantly share code, notes, and snippets.

@DeepDoge
Last active February 17, 2026 10:47
Show Gist options
  • Select an option

  • Save DeepDoge/22a3e7ec95183d23dd063eae4d22a343 to your computer and use it in GitHub Desktop.

Select an option

Save DeepDoge/22a3e7ec95183d23dd063eae4d22a343 to your computer and use it in GitHub Desktop.

Rust-Flavored TypeScript: Structs, Traits, and Impls Without Classes

Core Utilities (copy/paste)

export type Enum<T extends Record<string, (...args: any) => { readonly type: string }>> = ReturnType<T[keyof T]>;

export type Trait<Self = any> = {
	[key: string]: (self: Self, ...args: any) => any;
};

export type Impl<Self, Traits extends Trait<Self> = {}> = {
	[key: string]:
		| ((...args: any) => Promise<Self> | Self)
		| ((self: Self, ...args: any) => any);
} & Traits;

export type DefaultImpl<T extends Trait> = Partial<T>;

export type Dyn<T extends Trait> = {
	[K in keyof T]: T[K] extends (self: any, ...args: infer A) => infer R ? (...args: A) => R : T[K];
};

export function dyn<T extends Impl<Self>, Self>(
	impl: T,
	instance: Self,
): Dyn<ExtractTrait<T, Self>> {
	return new Proxy(impl, {
		get(target, prop, receiver) {
			const value = Reflect.get(target, prop, receiver);
			if (typeof value === "function") return (...args: any[]) => value(instance, ...args);
			return value;
		},
	}) as never;
}

type ExtractTrait<T, Self> = {
	[K in keyof T as T[K] extends (self: Self, ...args: any) => any ? K : never]: T[K];
};

Structs

Structs are plain data types (no methods).

export type Circle = { radius: number };
export type Square = { size: number };
export type Point = { x: number; y: number };

Struct composition uses nesting (not intersection).

// ✅ Nested composition
export type Square = {
	size: number;
	position: Point;
};

// ❌ Intersection
export type Square = Point & {
	size: number;
};

Traits

Traits are generic types. Every method takes self: Self as the first parameter. Self defaults to any.

export type Drawable<Self = any> = {
	draw(self: Self): void;
};

export type Resizeable<Self = any> = {
	resize(self: Self, factor: number): void;
};

Supertraits

A trait can require another trait via intersection.

export type Fancy<Self = any> = Drawable<Self> & {
	sparkle(self: Self): void;
};

Default Impls

A trait can have a companion helper (often a XDefaults<Self>() factory) that returns a default implementation.

export const DrawableDefaults = <Self>() =>
	({
		draw(self) {
			console.log(`Drawing ${JSON.stringify(self)}`);
		},
	}) satisfies DefaultImpl<Drawable<Self>>;

Impls

Impls are const objects (same name as the struct type). Always use satisfies on the object literal.

  • satisfies Impl<Self>: inherent methods only
  • satisfies Impl<Self, Traits>: inherent + trait methods

Rules for method typing:

  • Inherent methods (e.g. create) must have an explicit return type.
  • Trait methods must not annotate args/return; they get contextual types from satisfies.
export const Point = {
	create(x: number, y: number): Point {
		return { x, y };
	},
} satisfies Impl<Point>;

export const Circle = {
	...DrawableDefaults<Circle>(),
	create(radius: number): Circle {
		return { radius };
	},
} satisfies Impl<Circle, Drawable<Circle>>;

export const Square = {
	create(size: number): Square {
		return { size };
	},
	draw(self) {
		console.log(`Drawing a square with size ${self.size}`);
	},
	resize(self, factor) {
		self.size *= factor;
	},
} satisfies Impl<Square, Drawable<Square> & Resizeable<Square>>;

Usage:

const square = Square.create(10);
Square.draw(square);
Square.resize(square, 2);
Square.draw(square);

dyn() and Dyn (trait objects)

In this pattern, traits are functions over data: methods look like draw(self, ...).

Use Dyn<Trait> when you want a value that represents “some concrete type implementing this trait” — i.e. when you need to store or accept something by its trait rather than by its concrete struct type.

dyn(impl, instance) creates that trait object by binding instance to impl (so the returned methods don’t need an explicit self parameter).

Why it matters (storing a trait in a struct)

You can’t store an unbound trait surface (Drawable) and then pass a Circle:

export type DrawRunner = {
	// ❌ refers to the trait surface, not a bound instance
	drawable: Drawable;
};

export const DrawRunner = {
	create(drawable: Drawable): DrawRunner {
		return { drawable };
	},
} satisfies Impl<DrawRunner>;

// ❌ circle is data, not a Drawable
DrawRunner.create(circle);
//                ^^^^^^ 'Circle' is not assignable to 'Drawable'.

Instead, store a trait object: Dyn<Drawable>.

export type DrawRunner = {
	drawable: Dyn<Drawable>;
};

export const DrawRunner = {
	create(drawable: Dyn<Drawable>): DrawRunner {
		return { drawable };
	},
	run(self: DrawRunner) {
		self.drawable.draw();
	},
} satisfies Impl<DrawRunner>;

Now any struct with a Drawable impl can be wrapped and passed in:

const circle = Circle.create(5);

// ✅ Circle implements Drawable
const runner = DrawRunner.create(dyn(Circle, circle));
DrawRunner.run(runner);

Enums

Enums are discriminated unions built from a const object of variant constructors. The type is derived via the Enum utility.

export type Stride = Enum<typeof Stride>;
export const Stride = {
	fixed(size: number) {
		return { type: "fixed", size } as const;
	},
	variable() {
		return { type: "variable" } as const;
	},
};

Usage:

const s = Stride.fixed(4);
// s: { type: "fixed"; size: number }

if (s.type === "fixed") {
	console.log(s.size);
}

Or you can export each variant as its own type and annotate the constructors with explicit return types instead of as const:

export type StrideFixed = { type: "fixed"; size: number };
export type StrideVariable = { type: "variable" };

export type Stride = Enum<typeof Stride>;
export const Stride = {
	fixed(size: number): StrideFixed {
		return { type: "fixed", size };
	},
	variable(): StrideVariable {
		return { type: "variable" };
	},
};

This is useful when you need to refer to a specific variant by name (e.g. accepting only StrideFixed in a function signature).

Rules

  1. Structs are type aliases: pure data, no methods
  2. Struct composition uses nesting (no intersection types for “embedding”)
  3. Traits are generic types: type X<Self = any> = { method(self: Self, ...): ... }
  4. Impls are const objects: const X = { ... } satisfies Impl<X, Traits?>
  5. Inherent methods: explicit return type annotation required
  6. Trait methods: no arg/return annotations; types come from satisfies
  7. Methods return plain data (structs) only
  8. Compose traits with &
  9. Use Dyn<Trait> to type and dyn(impl, instance) to create trait objects — needed whenever you store or accept data by its trait rather than its concrete type

Splitting Impls Across Files

If you want to separate a trait impl into a different file (like Rust's impl Trait for Struct in another module), export the trait impl as a standalone const and spread it into the struct's main impl.

// drawable.ts
export type Drawable<Self = any> = {
	draw(self: Self): void;
};

export const CircleDrawableImpl = {
	draw(self) {
		console.log(`Drawing a circle with radius ${self.radius}`);
	},
} satisfies Impl<Circle, Drawable<Circle>>;
// circle.ts
import { CircleDrawableImpl } from "./drawable.ts";

export type Circle = {
	radius: number;
};

export const Circle = {
	...CircleDrawableImpl,
	create(radius: number): Circle {
		return { radius };
	},
} satisfies Impl<Circle>;

Not Recommended

❌ Using declare namespace impl + interface merging + Object.assign for trait impls

This pattern does work. It is not recommended because it is un-TypeScript-like — it leans on features designed for ambient/global type declarations (declare namespace, interface merging) to build local APIs, and papers over the gap with as casts and runtime Object.assign mutation.

Compare it directly with the Splitting Impls Across Files approach above, which achieves the exact same goal (trait impls defined in separate files) using only ordinary, idiomatic TypeScript:

Spread approach Namespace + Object.assign
Impl defined as… A single object literal with satisfies An empty object ({} as impl.X) mutated later
Type safety via… satisfies — compiler-verified, no trust required as cast — a lie you promise to fulfill yourself
Methods come from… Spread (...) at the definition site Object.assign side effects, anywhere, any time
TS features used… As designed (values, generics, type inference) Against the grain (ambient declarations for local code)

The spread approach is just normal TypeScript: you export a const, spread it into an object literal, and satisfies verifies everything. The namespace approach requires you to start with an empty object, assert it has a type that doesn't match yet (as impl.Circle), then fulfill that promise imperatively with Object.assign — something the compiler cannot check.

If you want Rust-like "impl blocks in other modules", prefer the spread approach.

Struct declaration (Not Recommended)

export type Circle = {
	radius: number;
};
declare namespace impl {
	interface Circle extends Impl<Circle> {}
}
export const Circle = {} as impl.Circle;

Inherent implementation (Not Recommended)

Inherent methods are declared by extending Circle via another declare namespace impl block with the method signatures. The actual implementations are then attached at runtime with Object.assign.

Notice the contrast with the spread approach: there, satisfies Impl<Circle> on the object literal guarantees every method exists. Here, the as impl.Circle cast up top already told the compiler the methods exist — the Object.assign call is just you keeping that promise manually.

declare namespace impl {
	interface Circle {
		create(radius: number): Circle;
	}
}
Object.assign(
	Circle,
	{
		create(radius: number): Circle {
			return { radius };
		},
	} satisfies Impl<Circle>,
);

Trait implementation (Not Recommended)

Implementing a trait for the struct means extending Circle with the trait type in another declare namespace impl block, then assigning the methods with Object.assign. satisfies on the Object.assign argument does check trait conformance, but only for that fragment — nobody checks that every declared interface extension actually has a corresponding Object.assign call somewhere.

With the spread approach, everything is in one object literal, so satisfies covers the whole impl at once.

export type Drawable<Self = any> = {
	draw(self: Self): void;
};

declare namespace impl {
	interface Circle extends Drawable<Circle> {}
}
Object.assign(
	Circle,
	{
		draw(self) {
			console.log(`Drawing a circle with radius ${self.radius}`);
		},
	} satisfies Impl<Circle, Drawable<Circle>>,
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment