Created
December 9, 2025 21:05
-
-
Save TorbenKoehn/64662d06383d0f50e89b9f228881de86 to your computer and use it in GitHub Desktop.
TypeScript Traits Concept
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /******************************************** | |
| * Base trait system | |
| * | |
| * Author: Torben Köhn <t.koehn@outlook.com> | |
| * License: MIT | |
| * | |
| *******************************************/ | |
| /** | |
| * Symbol to keep implemented trait references on the instance. | |
| * | |
| * Required for instanceof checks. | |
| */ | |
| const TraitSymbol = Symbol('traits') | |
| /** | |
| * A class that uses traits. | |
| */ | |
| export type TraitConsumer = { readonly [TraitSymbol]: Trait[] } | |
| export const isTraitConsumer = (value: unknown): value is TraitConsumer => { | |
| return ( | |
| typeof value === 'object' && | |
| value !== null && | |
| TraitSymbol in value && | |
| Array.isArray((value as TraitConsumer)[TraitSymbol]) | |
| ) | |
| } | |
| /** | |
| * A trait is a class that can be mixed into other classes to provide additional functionality. | |
| * | |
| * See it as an interface with implementation. | |
| * | |
| * Traits MUST be abstract classes and cannot be instantiated directly. | |
| * Traits MUST NOT have a constructor with parameters. | |
| * | |
| * Abstract methods can be used and will be propagated to the consuming class properly. | |
| */ | |
| export abstract class Trait { | |
| /** | |
| * Parameterless standard constructor for traits. | |
| */ | |
| constructor() {} | |
| /** | |
| * Allows instanceof checks for traits. | |
| * | |
| * @param instance The instance to check | |
| * @returns True if the instance implements the trait | |
| */ | |
| static [Symbol.hasInstance](instance: unknown) { | |
| return ( | |
| isTraitConsumer(instance) && | |
| instance[TraitSymbol].some((traitInstance) => traitInstance.constructor === this) | |
| ) | |
| } | |
| } | |
| /** | |
| * A constructor type for traits. | |
| * | |
| * Notice traits MUST be abstract classes. See {@link Trait}. | |
| */ | |
| export type TraitConstructor = abstract new () => Trait | |
| /** | |
| * Type helper to convert a union type to an intersection type. | |
| * | |
| * It will turn `A | B | C` into `A & B & C`. | |
| */ | |
| type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void | |
| ? I | |
| : never | |
| /** | |
| * Allows to create a class that implements one or more traits. | |
| * | |
| * Arguments need to be {@link TraitConstructor} types. | |
| * | |
| * Example: | |
| * ```ts | |
| * class Clone<T> extends Trait { | |
| * abstract clone(): T | |
| * } | |
| * | |
| * class Vector2 extends traits(Clone<Vector2>) { | |
| * constructor( | |
| * public x: number = 0, | |
| * public y: number = 0 | |
| * ) { | |
| * super() | |
| * } | |
| * override clone(): Vector2 { | |
| * return new Vector2(this.x, this.y) | |
| * } | |
| * } | |
| * ``` | |
| * | |
| * @param traits The trait constructors to implement | |
| * @returns A base class implementing the given traits | |
| */ | |
| export const traits = <const T extends readonly TraitConstructor[]>(...traits: T) => { | |
| return class implements TraitConsumer { | |
| readonly [TraitSymbol]: Trait[] = [] | |
| constructor() { | |
| for (const CurrentTrait of traits) { | |
| const traitInstance = Reflect.construct(CurrentTrait, []) | |
| const properties = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(traitInstance)) | |
| for (const [key, descriptor] of Object.entries(properties)) { | |
| if (key === 'constructor') { | |
| continue | |
| } | |
| Object.defineProperty(this, key, descriptor) | |
| } | |
| this[TraitSymbol].push(traitInstance) | |
| } | |
| } | |
| } as { | |
| new (): UnionToIntersection<InstanceType<T[number]>> | |
| } | |
| } | |
| /*************************** | |
| * Example traits and usage | |
| ***************************/ | |
| abstract class Serialize extends Trait { | |
| // Abstract methods work properly | |
| abstract serialize(): unknown | |
| } | |
| abstract class Deserialize extends Trait { | |
| abstract deserialize(data: unknown): void | |
| } | |
| abstract class Clone<T> extends Trait { | |
| abstract clone(): T | |
| } | |
| abstract class Stringify extends Trait { | |
| // Full implementations are no problem | |
| stringify(): string { | |
| return `[Object ${this.constructor.name}]` | |
| } | |
| } | |
| abstract class Print extends Trait { | |
| printId(): void { | |
| // Other traits can be checked via instanceof | |
| if (this instanceof Stringify) { | |
| console.log(`ID: stringify ${this.stringify()}`) | |
| return | |
| } | |
| console.log(`ID: toString ${this.toString()}`) | |
| } | |
| } | |
| // Using traits(A, B, C, ...) to "implement" traits | |
| class Foo extends traits(Serialize, Deserialize, Clone<Foo>, Stringify, Print) { | |
| override serialize(): unknown { | |
| return {} | |
| } | |
| override deserialize(data: unknown): void { | |
| // no-op | |
| } | |
| override clone(): Foo { | |
| return new Foo() | |
| } | |
| } | |
| const foo = new Foo() | |
| foo.printId() // ID: stringify [Object Foo] | |
| console.log(`foo instanceof Serialize: ${foo instanceof Serialize}`) // true | |
| console.log(`foo instanceof Deserialize: ${foo instanceof Deserialize}`) // true | |
| console.log(`foo instanceof Clone: ${foo instanceof Clone}`) // true | |
| console.log(`foo instanceof Print: ${foo instanceof Foo}`) // true | |
| /*************************** | |
| * Stateful example | |
| ***************************/ | |
| abstract class Counter extends Trait { | |
| // Fields/state is always propagated to the consuming class | |
| abstract count: number | |
| increment(): number { | |
| this.count++ | |
| return this.count | |
| } | |
| decrement(): number { | |
| this.count-- | |
| return this.count | |
| } | |
| } | |
| class StatefulFoo extends traits(Counter) { | |
| count = 0 | |
| logAndIncrement(): void { | |
| console.log(`Current count: ${this.count}`) | |
| this.increment() | |
| } | |
| logAndDecrement(): void { | |
| console.log(`Current count: ${this.count}`) | |
| this.decrement() | |
| } | |
| } | |
| const a = new StatefulFoo() | |
| a.logAndIncrement() // Current count: 0 | |
| a.logAndIncrement() // Current count: 1 | |
| a.logAndIncrement() // Current count: 2 | |
| const b = new StatefulFoo() | |
| b.logAndDecrement() // Current count: 0 | |
| b.logAndDecrement() // Current count: -1 | |
| b.logAndDecrement() // Current count: -2 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Ein Fix für das Typpredikat, damit subtypes von Requirements auch akzeptiert werden:
Damit kann man dann auch sowas hier machen