How to create incrementally a type-save object in TypeScript

Viewed 889

I'm sure there is an elegant solution for this typical pattern but I'm having a hard time to understand how to properly create an object incrementally in a type-save way. Basically I just want to create an object with a predefined type by initially creating an empty object and then adding the required properties. (I do understand that creating it at once does not cause this problem but my use case requires incremental creation)

The following examples show the options I have been trying and the problems with each approach:

type myType = {
    foo: string,
    bar: number,
};

// has errors
function createA(): myType {
    const o = {};

    o.foo = 'foo'; // Error: Property 'foo' does not exist on type '{}'
    o.bar = 0; // Error: Property 'bar' does not exist on type '{}'

    return o; // Error: Type '{}' is missing the following properties from type 'myType': foo, bar
}

// has errors
function createB(): myType {
    const o: myType = {}; // Type '{}' is missing the following properties from type 'myType': foo, bar

    o.foo = 'foo';
    o.bar = 0;

    return o;
}

// has errors
function createC(): myType {
    const o = {};

    o['foo'] = 'foo'; // Error: Element implicitly has an 'any' type because type '{}' has no index signature.
    o['bar'] = 0; // Error: Element implicitly has an 'any' type because type '{}' has no index signature.

    return o; // Error: Type '{}' is missing the following properties from type 'myType': foo, bar
}

// no errors but not type-save
function createD(): myType {
    const o: any = {};

    o.foo = 'foo';

    return o; // No error but object does not contain the required 'bar' property
}

// no errors but not type-save
function createE(): myType {
    const o: any = {};

    o.foo = 'foo';

    return o; // No error but object does not contain the required 'bar' property
}

// no errors but not type-save
function createF(): myType {
    const o: myType = <any>{};

    o.foo = 'foo';

    return o; // No error but object does not contain the required 'bar' property
}

// no errors but not type-save
function createG(): myType {
    const oFoo = {foo: 'foo'};
    const oBar = {bar: 0};

    return Object.assign(oFoo); // No error but object does not contain the required 'bar' property
}

// no errors and type-save but very convolute and expensive for a large number of properties
function createH(): myType {
    const foo = 'foo';
    const bar = 0;

    return {foo, bar}; 
}
3 Answers

You have two options:

  1. Use the Builder pattern.
  2. Use a simple alternative to the Builder pattern: the Expandable.

This type allows creating objects on an as-needed basis.

interface Expandable {
    extend<T>(properties: T): this & T;
}

You start with an empty object and add properties to it when you see fit.

declare const base: Expandable;

const result: MyType =
  base
    .extend({ foo: 'hello '})
    .extend({ bar: 42 });

If you want IntelliSense when you're extending the base, we can do it by making Expandable generic. A possible implementation:

type Slice<T> = { [P in keyof T]: Pick<T, P> }[keyof T];

class Extendable<T> {
    extend<U extends Slice<T>>(properties: U): this & U {
        return Object.assign(this, properties);
    }
}

function create(): MyType {
    const base = new Extendable<MyType>();

    return base
      .extend({ foo: 'hello' })
      .extend({ bar: 42 })
}

You could make MyType fields optional.

type MyType = {
  foo?: string;
  bar?: number;
};
But this approach leads to `undefined` checks.

Or you could simple use additional variables and create an object with expected type in the end.

type MyType = {
    foo: string,
    bar: number,
};

function create(): MyType {
    const foo = 'foo';
    const bar = 0;

    return {
        foo,
        bar
    }; // infers type
}
Possibly it isn't quite elegant but it keeps type-safety.

You can use Object.assign to create the object from parts that are created before hand. The result of Object.assign is an intersection of all parameter types. This means that if you forgot a property or have a wrong property type the result will not be compatible with myType

interface myType { foo: string, bar: number }
function createB(): myType {
    const oFoo = { foo: 'foo' };
    const oBar = { bar: 0 };
    return Object.assign(oFoo, oBar);
}

function createC(): myType {
    const oFoo = { foo2: 'foo' };
    const oBar = { bar: 0 };
    return Object.assign(oFoo, oBar); // err foo is missing
}


function createD(): myType {
    const oFoo = { foo: 'foo' };
    const oBar = { bar: "0" };
    return Object.assign(oFoo, oBar); // err bar  is string
}

This will not protect you from excess properties as that check is only done on object literals:

function createB(): myType {
    const oFoo = { foo: 'foo', foo2: 0 };
    const oBar = { bar: 0 };
    return Object.assign(oFoo, oBar); // still ok
}
Related