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