Dai
Dai

Reputation: 155558

What is the idiomatic way in TypeScript to extend an object (already implementing an interface) to a wider interface?

I have these DTO interfaces in TypeScript:

interface ProductDto {
    readonly productId: number;
    readonly name     : string;
}

interface FirstPartyProductDto extends ProductDto {
    readonly foo: string;
    readonly bar: number;
}

My application primarily uses server-side rendering but behaves like an SPA (this project is not using any SPA frameworks like Angular, Vue, etc). To aid the rehydration-process when the browser loads the page additional data is rendered to data- attributes.

So if a page contains a list-of-products, it would be rendered like so:

<ul class="productsList">
    <li
        data-product-id="1"
        data-product-name="Exploding toilet seat"
    >
    </li>
    <li
        data-product-id="2"
        data-product-name="Brussels sprout dispenser"
    >
    </li>
    <li
        data-product-id="3"
        data-product-name="Battery-tester tester"
    >
    </li>
</ul>

And the TypeScript to rehydrate ProductDto is straightforward:

static loadProductFromHtmlElement( e: HTMLElement ): ProductDto {
    return loadProductFromDataSet( e.dataset );
}

static loadProductFromDataSet( d: DOMStringMap ): ProductDto {
    return {
        productId: parseInt( d['productId']!, 10 ),
        name     : d['productName']!
    };
}

Now, supposing I want to rehydrate FirstPartyProductDto instances, then my code currently looks like this:

static loadFirstPartyProductFromDataSet( d: DOMStringMap ): FirstPartyProductDto {
    const productDto = loadProductFromDataSet( d );
    return {
        // ProductDto members:
        productId: productDto.productId,
        name     : productDto.name,

        // FirstPartyProductDto members:
        foo      : d['foo']!,
        bar      : parseInt( d['bar']!, 10 )
    };
}

I don't like how my code manually repeats the members of ProductDto as it populates the new returned object.

If this were untyped JavaScript, I could simply extend the existing productDto object instead:

static loadFirstPartyProductFromDataSet( d: DOMStringMap ): FirstPartyProductDto {
    const productDto = loadProductFromDataSet( d );
    productDto.foo = d['foo']!;
    productDto.bar = parseInt( d['bar']!, 10 );
    return productDto;
}

But the above code won't work because productDto is typed as ProductDto and so doesn't have the foo and bar properties, and even if I cast productDto as FirstPartyProductDto TypeScript won't let me assign those properties because they're readonly.

The only alternative I can immediately think of is to just cast productDto to any, but that means losing type-safety altogether.

There is also both Object.assign and the object spread operator ... which TypeScript supports, which certainly improves loadFirstPartyProductFromDataSet by avoiding the need to type-out all of the inherited properties...

function loadFirstPartyProductFromDataSet( d: DOMStringMap ): FirstPartyProductDto {
    const productDto = loadProductFromDataSet( d );
    return {
        ...productDto,

        foo: d['foo']!,
        bar: parseInt( d['bar']!, 10 )
    };
}

...but it's still copying properties and values to a new object rather than setting properties on the existing object.

Upvotes: 1

Views: 408

Answers (1)

jcalz
jcalz

Reputation: 329838

The only thing readonly properties prevent is explicitly setting a property value. They don't affect assignability; you can assign a value of type {a: string} to a variable of type {readonly a: string} and vice versa (see microsoft/TypeScript#13447 for more information). That means we can use a type function like

type Mutable<T> = { -readonly [K in keyof T]: T[K] };

which strips readonly off properties, and write a version of the code you tried using a type assertion (which is what you are calling a "cast"):

static loadFirstPartyProductFromDataSetAssert(d: DOMStringMap): FirstPartyProductDto {
    const productDto = Blah.loadProductFromDataSet(d) as Mutable<FirstPartyProductDto>;
    productDto.foo = d.foo!;
    productDto.bar = parseInt(d.bar!, 10);
    return productDto;
}

That's probably the easiest way to proceed. It isn't perfectly type safe, though, and you have to take care to actually set the asserted extra properties:

static loadFirstPartyProductFromDataSetAssertBad(d: DOMStringMap): FirstPartyProductDto {
    const productDto = Blah.loadProductFromDataSet(d) as Mutable<FirstPartyProductDto>;
    productDto.foo = d.foo!;
    // oops, forgot bar
    return productDto; // no error here
}

You could get a little more safety with a user-defined assertion function representing the gradual narrowing of an object type when you add properties, called set(obj, key, val). It would be used like this:

static loadFirstPartyProductFromDataSet(d: DOMStringMap): FirstPartyProductDto {
    const productDto = Blah.loadProductFromDataSet(d);
    // const productDto: ProductDto
    set(productDto, "foo", d['foo']!);
    // const productDto: {
    //   readonly productId: number;
    //   readonly name: string;
    //   foo: string;
    // } 
    set(productDto, "bar", parseInt(d['bar']!, 10));
    // const productDto: {
    //   readonly productId: number;
    //   readonly name: string;
    //   foo: string;
    //   bar: number;
    // } 
    return productDto; // okay
 }

And you can verify that it would give you an error if you left out "bar". The particular set() I used is defined as:

function set<T extends { [k: string]: any } & { [P in K]?: never }, K extends PropertyKey, V>(
    obj: T, key: K, val: V
): asserts obj is Extract<(T & Record<K, V> extends infer O ? { [P in keyof O]: O[P] } : never), T> {
    (obj as any).key = val;
}

This might be too complicated for your purposes, but I just wanted to demonstrate that it is possible to write code that adds properties to existing variables and get the type system to understand what you're doing.


Playground link to code

Upvotes: 1

Related Questions