Skip to content

Instantly share code, notes, and snippets.

@TorbenKoehn
Created December 9, 2025 21:05
Show Gist options
  • Select an option

  • Save TorbenKoehn/64662d06383d0f50e89b9f228881de86 to your computer and use it in GitHub Desktop.

Select an option

Save TorbenKoehn/64662d06383d0f50e89b9f228881de86 to your computer and use it in GitHub Desktop.
TypeScript Traits Concept
/********************************************
* 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
@RWalkling
Copy link

Coole implementierung, vor allem mit dem isinstance. Was ich noch hinzufügen würde, ist die Möglichkeit, traits zu extenden. Also z.B.

abstract class Print extends Stringify {
  override stringify(): string {
    return "only gets called if the final class does not override stringify"
  }

  printId(): void {
    console.log(`ID: stringify ${this.stringify()}`)
  }
}

Dazu die umgekehrte prototype chain

const prototypeChain = (obj: object): object[] => obj === null ? [] : [...prototypeChain(Object.getPrototypeOf(obj)), obj]

und dann in Zeile 55 stattdessen

instance[TraitSymbol].flatMap(prototypeChain).some((traitInstance) => traitInstance.constructor === this)

und in Zeile 111 dann die method resolution order implementieren

const properties: Readonly<Record<keyof any, PropertyDescriptor>> = Object.assign({}, ...prototypeChain(traitInstance).map(Object.getOwnPropertyDescriptors))

Außerdem ist es vielleicht sauberer so einen Typen hier zu benutzen

type Intersect<L extends readonly any[]> = L extends readonly [infer Head, ...infer Tail] ? Head & Intersect<Tail> : unknown

Um in Zeile 123 dann stattdessen

new (): Intersect<{[Index in keyof T]: InstanceType<T[Index]>}>

zu schreiben.

@RWalkling
Copy link

Um Trait-Requirements zu implementieren, könnte man ein Symbol RequiredTraitsSymbol hinzufügen und dem Typ trait einen static Member geben

static [RequiredTraitsSymbol] = [] as readonly TraitConstructor[]

dann ein Typ-Prädikat bauen

type RequirementsSatisfied<Requirements extends readonly TraitConstructor[], Existing> =
  Requirements extends readonly [infer Head, ...infer Tail]
    ? Head extends TraitConstructor & Existing
      ? RequirementsSatisfied<
            Tail extends readonly TraitConstructor[] ? Tail : never, 
            Existing
        >
      : false
    : true

type SatisfiesTraitOrder<Traits extends readonly TraitConstructor[], Existing = never> =
  Traits extends readonly [infer Head, ...infer Tail]
    ? Head extends TraitConstructor
      ? RequirementsSatisfied<
            Head[typeof RequiredTraitsSymbol] extends readonly TraitConstructor[] ? Head[typeof RequiredTraitsSymbol] : never, 
            Existing
        > extends true
        ? SatisfiesTraitOrder<
          Tail extends readonly TraitConstructor[] ? Tail : never,
          Existing | Head
        >
        : false
      : false
    : true

und dann in Zeile 122 - 124 stattdessen so was wie

  } as SatisfiesTraitOrder<T> extends true ? {
    new (): Intersect<{[Index in keyof T]: InstanceType<T[Index]>}>
  } : never

schreiben. Wenn man dann z.B.

abstract class Print extends Stringify {
  static [RequiredTraitsSymbol] = [Stringify] as const
  ...
}

hat und dann versucht, von trait(Print) zu extenden, bekommt man einen Type-Error. Bei trait(Stringify, Print) aber nicht. Man kann dann auch zur Runtime Reflection über die Abhängigkeiten machen, indem man einfach die RequiredTraitsSymbol-Property ausliest.

@RWalkling
Copy link

Ein Fix für das Typpredikat, damit subtypes von Requirements auch akzeptiert werden:

type RequirementSatisfied<Requirement extends TraitConstructor, Existing extends readonly TraitConstructor[]> =
  Existing extends readonly [infer Head, ...infer Tail]
    ? Head extends Requirement
      ? true
      : RequirementSatisfied<
            Requirement,
            Tail extends readonly TraitConstructor[] ? Tail : never
        >
    : false

type RequirementsSatisfied<Requirements extends readonly TraitConstructor[], Existing extends readonly TraitConstructor[]> =
  Requirements extends readonly [infer Head, ...infer Tail]
    ? Head extends TraitConstructor
      ? RequirementSatisfied<Head, Existing> extends true
        ? RequirementsSatisfied<
              Tail extends readonly TraitConstructor[] ? Tail : never, 
              Existing
          >
        : false
      : false
    : true

type SatisfiesTraitOrder<TraitsSuffix extends readonly TraitConstructor[], TraitsPrefix extends readonly TraitConstructor[] = []> =
  TraitsSuffix extends readonly [infer Head, ...infer Tail]
    ? Head extends TraitConstructor
      ? RequirementsSatisfied<
            Head[typeof RequiredTraitsSymbol] extends readonly TraitConstructor[] ? Head[typeof RequiredTraitsSymbol] : never, 
            TraitsPrefix
        > extends true
        ? SatisfiesTraitOrder<
          Tail extends readonly TraitConstructor[] ? Tail : never,
          readonly [...TraitsPrefix, Head]
        >
        : false
      : false
    : true

Damit kann man dann auch sowas hier machen

abstract class Print extends traits(Stringify, Serialize) {
  static [RequiredTraitsSymbol] = [Stringify, Serialize] as const
  printId(): void {
    console.log(`ID: stringify ${this.stringify()} and ${this.serialize()}`)
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment