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