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 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 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;
};A trait can require another trait via intersection.
export type Fancy<Self = any> = Drawable<Self> & {
sparkle(self: Self): void;
};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 are const objects (same name as the struct type). Always use satisfies on the object literal.
satisfies Impl<Self>: inherent methods onlysatisfies 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);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).
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 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).
- Structs are
typealiases: pure data, no methods - Struct composition uses nesting (no intersection types for “embedding”)
- Traits are generic types:
type X<Self = any> = { method(self: Self, ...): ... } - Impls are
constobjects:const X = { ... } satisfies Impl<X, Traits?> - Inherent methods: explicit return type annotation required
- Trait methods: no arg/return annotations; types come from
satisfies - Methods return plain data (structs) only
- Compose traits with
& - Use
Dyn<Trait>to type anddyn(impl, instance)to create trait objects — needed whenever you store or accept data by its trait rather than its concrete type
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>;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.
export type Circle = {
radius: number;
};
declare namespace impl {
interface Circle extends Impl<Circle> {}
}
export const Circle = {} as impl.Circle;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>,
);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>>,
);