Class decorators vs mixins

Class decorators are an experimental feature of typescript 4.0 and mixins were supported since typescript 2.2 at least. Unfortunately class decorators do have drawbacks with respect to the type recognition because added attributes are not recognized as is. Here is a comparison of class decorators and mixins after an excellent article from Marius Shulz.

We see on two examples how using decorators is more verbose than using mixins.

Timestamps

Timestamping instances at creation time (TS playground).

function Timestamped<
  TBase extends { new(...args: any[]): {} }
>(Base: TBase) {
  return class extends Base {
    timestamp = Date.now()
  }
}
// Create a new class by mixing `Timestamped` into an anonymous class
const TimestampedUser = Timestamped(class {
  constructor(public name: string) {}
})
// Instantiate the new `TimestampedUser` class
const user = new TimestampedUser("John Doe")
console.log(user.name)
console.log(user.timestamp)

Here is the same functionality with a decorator (TS playground)

interface ITimestamped {
  timestamp: Date
}
function Timestamped<
  TBase extends { new(...args: any[]): {} }
>(Base: TBase) {
  return class extends Base implements ITimestamped {
    timestamp = Date.now()
  }
}
// Create a new class by decorating class `TimestampedUser`
@Timestamped
class TimestampedUser{
  constructor(public name: string) {}
}
// Instantiate the new `TimestampedUser` class
const user = new TimestampedUser("John Doe") as TimestampedUser & ITimestamped
console.log(user.name)
console.log(user.timestamp)

With a Constructor

Mixins version

(TS playground)

function Tagged<
  TBase extends { new(...args: any[]): {} }
>(Base: TBase) {
  return class extends Base {
    tag?: string
    constructor(...args: any[]) {
      super(...args)
      try {this.tag = args[1]}
      finally {}
    }
  }
}
// Create a new class by mixing `Tagged` into an anonymous class
const TaggedUser = Tagged(class {
  name: string
  constructor(...args: any[]) {
    try {this.name = args[0]}
    finally {}
  }
})
const user = new TaggedUser("Jane Doe", "janedoe")
console.log(user.name)
console.log(user.tag)

Nota bene

In the previous code, we have two constructors. TypeScript implicitly forces them to have somehow the same signature. In that example, we also added a new argument to the constructor of the class obtained by mixins.

Class decorator version

TS playground

interface ITagged {
  tag?: string
}
function Tagged<
  TBase extends { new(...args: any[]): {} }
>(Base: TBase) {
  return class extends Base {
    tag?: string
    constructor(...args: any[]) {
      super(...args)
      try {this.tag = args[1]}
      finally {}
    }
  }
}
// Create a new class by mixing `Tagged` into an anonymous class
@Tagged
class TaggedUser {
  name: string
  constructor(...args: any[]) {
    try {this.name = args[0]}
    finally {}
  }
}
const user = new TaggedUser("Jane Doe", "janedoe") as TaggedUser & ITagged
console.log(user.name)
console.log(user.tag)

Using generics to decorate a class

We previously explained how to add new attributes to a class using a decorator and properly use the decorated class accordingly to the type system. Here we use a property which type is a generic and a method which argument is a generic.

Add a property

Here we add a property named data which is an instance of some Data class.

class Data {
    name: string = ''
}
interface IFoo<D extends Data> {
  data?: D
}
function decorate<
  T extends { new (...args: any[]): {} },
  D extends Data
>(constructor: T) {
  return class extends constructor implements IFoo<D> {
    data?: D
  }
}
@decorate
class Foo {
  static new<D extends Data>() {
    return new Foo() as TFoo<D>
  }
}
type TFoo<D extends Data> = Foo & IFoo<D>

Here is how one can use it

var foo = Foo.new()
console.log(foo.data)
foo.data = {
  name: 'blabla'
}
console.log(foo.data.name)

We can also decorate using a strict subclass of data

class XData extends Data {
    x = true
}
@decorate
class Bar {
  static new<D extends XData>() {
    return new Bar() as TBar<D>
  }
}
type TBar<D extends XData> = Bar & IFoo<D>

Here is how one can use it

var bar = Bar.new()
bar.data = {
  name: 'blabla',
  x: true
}
console.log(bar.data.name)

If we want Foo to also accept Data subclasses, we must add any string signature to Data:

class Data {
    [$: string]: any
    name: string = ''
}

Here is how one can use it

foo.data = {
  name: 'blabla',
  x: true
}
console.log(foo.data.name)

Add a method

Here we add a method named test which sole argument is an instance of some Data.

class Data {
    [$: string]: any
    name: string = 'foo'
}
interface IFoo<D extends Data> {
  test(data: D): void
}
function decorate<
  T extends { new (...args: any[]): {} },
  D extends Data
>(constructor: T) {
  class U extends constructor implements IFoo<D> {
    test(data: D) {
      return data.name
    }
  }
  return U
}
@decorate
class Foo {
  static new<D extends Data>() {
    return new Foo() as TFoo<D>
  }
}
type TFoo<D extends Data> = Foo & IFoo<D>

Here is how we can use it

var foo = Foo.new()
console.log(foo.test(new Data()))

Subclassing the Data class

class XData extends Data {
    x = 421
}
@decorate
class Bar {
  static new<D extends XData>() {
    return new Bar() as TBar<D>
  }
  constructor(name: string) {
    super()
    this.name = name
  }
}
type TBar<D extends XData> = Bar & IFoo<D>

and some usage

var bar = Bar.new()
console.log(bar.test(new XData('bar')))

console.log(foo.test(new XData('baz')))

Playgrounds

Typescript playground to add a property.
Typescript playground to add a method.

Decorate a class with new attributes

Typescript 4.

Using a decorator to add new methods to a class is not done seamlessly. The main problem is that decorated classes are not properly used within the type system. We thus need a factory method to create instances.

We start with an interface to declare the optional new attributes foo

interface IFoo {
  foo: string
}

The decorator adds the foo property

function decorate<
  T extends {
    new (...args: any[]): {}
  }
>(
  constructor: T
) {
  class U extends constructor {
    foo: string = 'foo'
  }
  return U
}

Then we decorate a class and define a new type to extend the class and define a factory instance maker.

@decorate
class Foo {
  bar: string = 'bar'
  static new() {
    return new Foo() as TFoo
  }
}
type TFoo = Foo & IFoo

Finally we are able to use the new decorated class. The type system won’t complain provided we use the TFoo typecast, either directly or through the static factory Foo.new

var foo = Foo.new()
console.log(foo.foo, foo.bar)
foo = new Foo() as TFoo
console.log(foo.foo, foo.bar)

Here is the Typescript playground.