Parent child paradigm

A parent may have a child, a child may have a parent, but no more than one. Is it possible to make the difference using types ?

Here are the classes

class Child <T extends Parent | undefined> {
  parent?: T
}
class Parent {
  child?: Child<this>
  change($: Child<undefined>): Child<undefined> | undefined {
    const ans = this.child
    const $$ = $ as unknown as Child<this>
    $$.parent = this
    this.child = $$
    return ans as unknown as Child<undefined>
  }
}

And usage:

const parent1 = new Parent()
const parent2 = new Parent()
parent1.change(new Child())
parent2.change(parent1.child)

Last line expectedly produces an error as parent1.child already has a parent!

There is a minor problem however: the extra variable $$ was used to avoid typecasting multiple times. We hope this is optimized by the transpiler/minifier used before shipping. Actually tsc does not optimize that code.

Subclassing Map with mixins

The problem arises because Map is defined with generics.

TS playground

Given a mixins extension

This extension just adds a creation_date attribute to any instance.

type Constructor = new (...args: any[]) => {}
function extension<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    creation_date = Date.now()
  }
}
class Foo extends extension(class {}) {}
console.log(new Foo().creation_date)

Extending Map

What we would like:

...
const mn = new XMap<string, number>()
console.log(mn.creation_date)
mn.set('foo', 123)
console.log(mn.get('foo'))

const ms = new XMap<string, string>()
console.log(ms.creation_date)
ms.set('foo', 'bar')
console.log(ms.get('foo'))

class Foo<K, V> {
  map?: Map<K, V>
}

Actually, we have

function mapExtension<K, V>() {
  return extension(class extends Map<K, V>{})
}

const MapNumber = mapExtension<string, number>()
const mn = new MapNumber()
console.log(mn.creation_date)
mn.set('foo', 123)
console.log(mn.get('foo'))

const MapString = mapExtension<string, string>()
const ms = new MapString()
console.log(ms.creation_date)
ms.set('foo', 'bar')
console.log(ms.get('foo'))

This is too wordy, although tolerable. But the most important thing is that we lost the Map generics.

Overriding a method signature with optional arguments

When overriding a method, we may want to add a new formal argument. This may be the case for constructors. If the signature is mixing required and optional arguments, things may become difficult, without mentioning class mixins and decorators.

When you have a strongly designed base class, you can safely override a method in the subclass and choose there whatever signature you want. But if you happen to change the signature in the base class, you must propagate the changes down the hierarchy to update accordingly all the overridden methods’ signatures. This is a change in design that may have big consequences, and as such is prone to bugs.

Required and optional arguments

TS playground

Here is a base class

class Parent {
  test(foo1: string, bar1: string = 'bar1') {
    console.log(foo1, bar1)
  }
}
new Parent().test('foo1')

we override test adding both a required and an optional parameter

class Child extends Parent {
  test(foo1: string, foo2: string, bar1: string = 'bar1', bar2: string = 'bar2') {
    super.test(foo1, bar1)
    console.log(foo2, bar2)
  }
}
const child = new Child()
child.test('foo1', 'foo2')
child.test('foo1', 'foo2', 'baz1')
child.test('foo1', 'foo2', undefined, 'baz2')
  • The default value of the bar1 is defined twice
  • We must explicitly use undefined in order to specify only the second optional argument.
  • No more than 3 or 4 arguments stay legible.

Key Valued arguments

When positional arguments are not practical nor efficient, we can make use of key valued arguments. Each method has a unique argument with a well defined type. Here is the previous example revisited.

type test_r = {
  foo1: string
  bar1?: string
}
class Parent {
  test($: test_r) {
    console.log($.foo1, $.bar1 || 'bar1')
  }
}
new Parent().test({
  foo1: 'foo1'
})

In order to override the test method, we start by extending test_r.

type test_r_r = test_r & {
  foo2: string
  bar2?: string
}
class Child extends Parent {
  test($: test_r_r) {
    super.test($)
    console.log($.foo2, $.bar2 || 'bar2')
  }
}
const child = new Child()
const foo1 = 'foo1'
const foo2 = 'foo2'
const bar1 = 'baz1'
const bar2 = 'baz2'
child.test({foo1, foo2})
child.test({foo1, foo2, bar1})
child.test({foo1, foo2, bar2})
  • This is more verbose
  • this is more legible
  • The default values are managed in the code, not in the arguments (in general, composed default values must be managed in the code).

Class Mixins

TS playground

We first define a User class with a key valued unique constructor argument

type new_r = {
  [$: string]: any
  name: string
}
class User {
  name: string
  constructor($: new_r) {
    this.name = $.name
  }
}
const user = new User({
  name: "John Doe"
})
console.log(user.name)

Then we define a Tagged mixins function

type Tagged_r<R> = R & {
  tag: string
}
function Tagged<
  TBase extends { new(...args: any[]): {} },
  new_R
>(Base: TBase) {
  return class extends Base {
    tag?: string
    constructor(...args: any[]) {
      const $ = args[0] as Tagged_r<new_R>
      super($)
      this.tag = $.tag
    }
  }
}
// Create a new class by mixing `Tagged` into an anonymous class that we extend
// to restrict the constructor signature.
class TaggedUser extends Tagged(User) {
  constructor($: Tagged_r<new_r>) {
    super($)
  }
}
const tagged_user = new TaggedUser({
  name: "Jane Doe",
  tag: 'janedoe'
})
console.log(tagged_user.name)
console.log(tagged_user.tag)

We have more possibilities

new User({
  name: "Jane Doe",
  tag: 'janedoe'
})
new User(user)
new User(tagged_user)
new TaggedUser(tagged_user)
  • new_r accepts any string key to allow more possibilities
  • there is one ‘do nothing’ class layer: not a very clean design

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.