Reputation: 155558
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
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.
Upvotes: 1