首页 > 解决方案 > 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.

标签: typescript

解决方案


唯一readonly阻止属性的事情是显式设置属性值。它们不影响可分配性;您可以将类型值分配给类型{a: string}变量,{readonly a: string}反之亦然(有关更多信息,请参阅microsoft/TypeScript#13447)。这意味着我们可以使用类型函数,例如

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

它剥离readonly了属性,并编写了您尝试使用类型断言的代码版本(这就是您所说的“强制转换”):

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;
}

这可能是最简单的方法。但是,它不是完全类型安全的,您必须注意实际设置断言的额外属性:

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
}

当您添加属性时,您可以使用用户定义的断言函数来表示对象类型的逐渐缩小,从而获得更多的安全性,称为set(obj, key, val). 它将像这样使用:

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
 }

并且您可以验证如果您遗漏了它会给您一个错误"bar"。我使用的具体set()定义为:

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;
}

这对于您的目的来说可能太复杂了,但我只是想证明可以编写代码,将属性添加到现有变量并让类型系统了解您在做什么。


Playground 代码链接


推荐阅读