Blog-Archiv

Freitag, 1. Juni 2018

Static Factory and Generics in TypeScript

In my last Blog I showed how a static factory could make sense when needing overrides already at construction time. In this Blog I want to improve that, trying to avoid repetitive code in the static factories:

FirstName.ts
    public static newFirstName(): FirstName {
        const firstName: FirstName = new FirstName();
        firstName.init();
        return firstName;
    }
LastName.ts
    public static newLastName(): LastName {
        const lastName: LastName = new LastName();
        lastName.init();
        return lastName;
    }

For this I need to look into TypeScript (TS) generics.

Generics Example

Swapping two array elements is a classic example where a function needs to be reusable for different data-types. We want to sort arrays that contain strings as well as such that contain numbers or objects. Following shows how to write such a generic function in TS, including two example calls.

function swap<T>(array: T[], firstIndex: number, secondIndex: number): void {
    const newSecond: T = array[firstIndex];
    array[firstIndex] = array[secondIndex];
    array[secondIndex] = newSecond;
}

const stringArray: string[] = [ "One", "Two", "Three" ];
swap(stringArray, 0, 2);

const numberArray: number[] = [ 1, 2, 3 ];
swap(numberArray, 0, 2);

The generic data-type is denoted the by the <T> after the swap function name. You could use any letter or name. Convention is to keep generics upper-case, to indicate their meta-data role.

It is the same syntax as Java generics have. Like in Java, also <T extends SomeClass> is possible inside the angle brackets, but not <T super SomeClass> (which would restrict the possible types to super-classes of SomeClass).

Following example

    init<T extends Name>(instance: T): T {
        // ....
        return instance;
    }

reads as:

init() uses a type T that must be at least Name, but can be also a sub-class of Name.

The incoming parameter must be of that type, and the returned object will be of that type. You can use T also for local variables.

So let's try to use generics for improving our static factories.

Generic Initialization

We must keep the constructors of all sub-classes of the common super-class Name protected to avoid factory circumventions. Thus we still need to have a static factory in each sub-class, because just the class itself has access to its protected constructor, but that factory will be just one line of code.

Here is the new super-class with its generic static init() function:

Name.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
export abstract class Name
{
    /** Static generic factory. */
    public static init<T extends Name>(instance: T): T {
        instance.init();
        return instance;
    }
    
    private value: string;

    /** Protect construction to allow factory only. */
    protected constructor(name?: string) {
        if (name !== undefined)
            this.value = name;
    }
    
    /** Object initialization, to be called by factory. */
    protected init(): void {
        if (this.value === undefined)
            this.value = this.getDefault();
    }

    /** Sub-classes must define a default. */
    protected abstract getDefault(): string;
    
    /** Expose the value readonly. */
    public getValue(): string {
        return this.value;
    }
}

The generic static init() function declares the data-type it handles, and calls init() on the instance it receives (see line 5 and 18), then it returns the instance (see line 6).

There is something new in abstract class Name: an optional name can be passed to the constructor, making the example a little more realistic. Because of that, the initialization of the default can take place just when the value is still empty after construction.

Refactored Factories

Now here comes the one line of code in the refactored sub-class:

FirstName.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { Name } from "./Name.js";

export class FirstName extends Name
{
    public static newInstance(name?: string): FirstName {
        return Name.init(new FirstName(name));
    }
    
    public readonly defaultValue: string = "(No Firstname)";

    protected constructor(name?: string) {
        super(name);
    }
    
    protected getDefault(): string {
        return this.defaultValue;
    }
}

The static factory newInstance() needs to define exactly the same parameters as the constructor does. It creates a new instance, passing it to the super-class Name.init(), and returns whatever that returns (see line 6). The TS compiler guesses T from the type of the parameter, the caller doesn't need to declare it explicitly. This is called "type inference".

Here is the second sub-class:

LastName.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { Name } from "./Name.js";

export class LastName extends Name
{
    public static newInstance(name?: string): LastName {
        return Name.init(new LastName(name));
    }
    
    public readonly defaultValue: string = "(No Lastname)";

    protected constructor(name?: string) {
        super(name);
    }
    
    protected getDefault(): string {
        return this.defaultValue;
    }
}

That's the way to keep all kinds of initialization inside the static init() of the super-class.
Here is test code for it:

import { FirstName } from "./FirstName.js";
import { LastName } from "./LastName.js";


const firstName: FirstName = FirstName.newInstance();
console.assert(
    firstName.getValue() === firstName.defaultValue,
    "firstName.getValue() is expected to be '"+firstName.defaultValue+"': '"+firstName.getValue()+"'");

const PETER: string = "Peter";
const firstName2: FirstName = FirstName.newInstance(PETER);
console.assert(
    firstName2.getValue() === PETER,
    "firstName2.getValue() is expected to be '"+PETER+"': '"+firstName2.getValue()+"'");


const lastName: LastName = LastName.newInstance();
assert(
    lastName.getValue() === lastName.defaultValue,
    "lastName.getValue() is expected to be '"+lastName.defaultValue+"': '"+lastName.getValue()+"'");

const LEWIS: string = "Lewis";
const lastName2: LastName = LastName.newInstance(LEWIS);
assert(
    lastName2.getValue() === LEWIS,
    "lastName2.getValue() is expected to be '"+LEWIS+"': '"+lastName2.getValue()+"'");

This shows:

firstName.getValue() is expected to be '(No Firstname)': '(No Firstname)'
firstName2.getValue() is expected to be 'Peter': 'Peter'
lastName.getValue() is expected to be '(No Lastname)': '(No Lastname)'
lastName2.getValue() is expected to be 'Lewis': 'Lewis'

Conclusion

Of course things get more complicated in reality. All logic normally done in constructor needs to move to the static factory of a specific class. Being there it is not overridable and reusable any more, because static implementations are no more object-oriented. When having complex initialization logic it will be recommendable to write a dedicated builder and pass an instance of that alternatively to the static factory.




Keine Kommentare: