How can one effectively broaden the interface of an object in TypeScript that is already implementing an interface in an idiomatic manner?

In my TypeScript project, I have defined these DTO interfaces:

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

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

The application I am working on uses server-side rendering but acts like a single-page application without relying on frameworks like Angular or Vue. To assist with the rehydration process when the page loads in the browser, additional data is included in data- attributes.

For example, if a page has a list of products, it might be rendered as follows:

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

The TypeScript code to rehydrate the ProductDto is pretty straightforward:

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

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

If I want to rehydrate instances of FirstPartyProductDto, the current approach involves manually copying over the members from ProductDto:

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 find this repetition of members between the DTOs cumbersome and inelegant. In untyped JavaScript, I could simply extend the existing object, but that's not feasible here due to type restrictions and read-only properties.

An alternative solution is to use Object.assign or the object spread operator in TypeScript, which can simplify the code by avoiding manual property assignments:

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

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

While this improves the code slightly by reducing the need for explicit property assignments, it still involves creating a new object instead of directly modifying the existing one.

Answer №1

When using the readonly keyword on properties, it only prevents explicit property value setting. It does not impact assignability; you can still assign a type of {a: string} to a variable of type {readonly a: string} interchangeably (refer to microsoft/TypeScript#13447 for more details). This allows us to create a type function like

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

which removes the readonly modifier from properties. We can then modify the code using a type assertion (referred to as 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;
}

This method is relatively straightforward, though not entirely type safe. You must ensure that the asserted extra properties are set correctly:

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
}

To enhance safety, you can utilize a user-defined assertion function that gradually narrows an object type when adding properties, such as set(obj, key, val). It can be implemented as follows:

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

You can verify that this approach would throw an error if "bar" was omitted. The specific set() function 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;
}

While this may be complex for your needs, it demonstrates the possibility of writing code that adds properties to existing variables while maintaining type system understanding.


(Playground link to code)

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Looping to run an async process (sequilize.authenticate) multiple times until successful

I need my microservice to wait for the database to become available before proceeding. There is a sidecar Cloud SQL proxy involved that requires some time for the database connection. My current approach involves a for loop that retries connecting after a ...

Encountering unspecified values when subscribing to a BehaviorSubject and receiving it as an Observable

My goal is to display the name of the currently logged-in user in the header of my application. However, I encountered an issue where upon refreshing the page, the value would be lost due to SPA behavior (even though the data is stored in local storage). T ...

Dealing with mouseover and mouseout events for ul li elements in Angular 9: An easy guide

Having trouble showing and hiding the span tag using mouseover and mouseout events. The ul and li elements are generating dynamically, so I attempted to toggle the display between block and none but it is not working as expected. Does anyone have a solutio ...

Troubleshooting asynchronous functions in Ionic3 when using Firebase Storage and Firestore

Attempting to retrieve the downloadURL from an uploaded image. The uploadImage function is used to upload the image to Firebase Storage. uploadImage() { this.image = 'movie-' + new Date().getTime() + '.jpg'; let storageRef: any, ...

Observable doesn't respond to lazy loaded module subscriptions

I am trying to understand why my lazy loaded module, which loads the test component, does not allow the test component to subscribe to an observable injected by a test service. index.ts export { TestComponent } from './test.component'; export { ...

Vitest surpasses Jest by providing explicit type declarations, ensuring no more "unknown type" errors

Transitioning from Jest to vitest has been a smooth process for me. I'm currently in the midst of converting the following code snippets: // Jest const myLib = jest.requireActual("mylib.js") to this: // Vitest const myLib = await vi.importA ...

IE11 is throwing a fit because of a pesky long-running script error caused by the powerful combination of Webpack, React,

Utilizing webpack 4.* for bundling my react 16.* and typescript 3.* project has been causing issues on internet explorer 11. I consistently encounter a "not responding long running script error" on both local and test servers (in production mode). The lac ...

"Customizing API requests based on specific conditions with n

For a specific scenario, I need to login as an admin in one case and as a regular user in another. signIn$ = createEffect(() => this.actions$.pipe( ofType(AuthActions.signInRequest), exhaustMap(({ variables, redirectTo, hasAdmin }) =&g ...

The Angular Http Interceptor is failing to trigger a new request after refreshing the token

In my project, I implemented an HTTP interceptor that manages access token refreshing. If a user's access token expires and the request receives a 401 error, this function is designed to handle the situation by refreshing the token and re-executing ...

Using TypeScript, create a functional component for a private route in React

When I encounter the error message below, can you please explain where this issue is originating from? No overload matches this call. Overload 1 of 2, '(props: Readonly<RouteProps>): Route<RouteProps>', gave the following error. ...

Taunting a specific occurrence inside a group

Good evening, I am currently in the process of developing tests for the TypeScript class shown below. My goal is to create a test that ensures the postMessage method of the internal BroadcastChannel is called. However, I am facing difficulties in setting ...

What is the process for converting an image into base 64 using Angular 5?

Is there a way to convert an image into base 64 using angular5 when the image is sourced from Facebook or Google authentication API? I seem to be encountering an issue, what could I be doing wrong? getBase64Image(img) { var canvas = document.createEleme ...

What is the best way to save code snippets in Strapi for easy integration with SSG NextJS?

While I realize this may not be the typical scenario, please listen to my situation: I am using Strapi and creating components and collections. One of these collections needs to include code snippets (specifically typescript) that I have stored in a GitH ...

The process of linking a Json response to a table

In my products.components.ts class, I am retrieving Json data into the variable this.Getdata. ngOnInit() { this._service.getProducts(this.baseUrl) .subscribe(data => { this.Getdata=data this.products=data alert(JSON.stringify(this.Getdata)); ...

Unpacking objects in Typescript

I am facing an issue with the following code. I'm not sure what is causing the error or how to fix it. The specific error message is: Type 'CookieSessionObject | null | undefined' is not assignable to type '{ token: string; refreshToken ...

Guide on how to import or merge JavaScript files depending on their references

As I work on my MVC 6 app, I am exploring a new approach to replacing the older js/css bundling & minification system. My goal is to generate a single javascript file that can be easily referenced in my HTML. However, this javascript file needs to be speci ...

Deleting specialized object using useEffect hook

There's a simple vanilla JS component that should be triggered when an element is added to the DOM (componentDidMount) and destroyed when removed. Here's an example of such a component: class TestComponent { interval?: number; constructor() ...

Include a <button> element in the Angular ng2-pdf-viewer framework

I am looking to showcase a PDF file on my component using ng2-pdf-viewer. One of the requirements is to include a download button that overlaps the PDF file. I have searched for references on how to achieve this but unfortunately, I haven't found any ...

Is it possible to choose a range in ion2-calendar starting from the day after tomorrow and spanning three months ahead?

Currently, I have set up an ion-calendar utilizing the ion2-calendar plugin. The calendar is configured to disable dates prior to today's date. However, my goal is to also disable "today" and display available dates starting from tomorrow. Additionall ...

Stop fullscreen mode in Angular after initiating in fullscreen mode

Issue with exiting full screen on Angular when starting Chrome in full-screen mode. HTML: <hello name="{{ name }}"></hello> <a href="https://angular-go-full-screen-f11-key.stackblitz.io" target="_blank" style=& ...