Skip to content

Improved type definitions for JSON api responses #33037

Closed
@Choc13

Description

@Choc13

Search Terms

fetch body json generic

Suggestion

There are actually two suggestions here that would work together. I'll present them separately.

  1. A generic version of the json() method on a fetch Response with the following signature: json<T>(): Promise<T>.

  2. A Json<T> type that is equivalent to T only if T contains properties that can be represented as valid JSON. Implementation below. The main thing here is to enforce that any properties of type Date in T are correctly mapped from a string in order to convert Json<T> to T.

Use Cases

The use cases for both features are pretty much the same so I'll deal with them together.
When fetching data from an API we create types to represent the data that is being returned. For example, say we have the following type:

type Foo = {
    name: string;
    date: Date;
};

And we want to fetch one from an API then we'll write a function like so:

async function getFoo(): Promise<Foo> {
    const response: Response = await fetch('https://example.com/foo');
    return await response.json();
}

The problem here is that elsewhere in the application if we try use the date property on a Foo then it won't actually be of type Date, but most likely will be a string because that is how it is often represented in JSON. This leads to runtime errors, usually along the lines of some Date method being undefined for string.

Obviously we could put this down to bad programming and recognise that we should create some type to represent the data that's actually being returned from the API, for example:

type FooDto = {
    name: string;
    date: string;
};

and then update the API function to be:

async function getFoo(): Promise<Foo> {
    const response: Response = await fetch('https://example.com/foo');
    const foo: FooDto =  await response.json() as FooDto;
    return {
        ...foo,
        date: new Date(foo)
    };
}

However, it places a large burden on the developer when this needs to be done for many types and it is still error prone if the developer forgets to create the DTO type.

In our projects we have found a better way to mitigate this problem by defining a Json<T> type like so:

export type Json<T> = 
    T extends Date ? string :
    T extends Array<(infer U)> ? JsonArray<U> :
    T extends object ? { [K in keyof T]: Json<T[K]> } :
    T;

interface JsonArray<T> extends Array<Json<T>> { }

We can then define a function get<T> along these lines:

async function get<T>(url: string): Promise<Json<T>> {
    const response: Response = await fetch(url);
    return await response.json()
}

And so our implementation of getFoo becomes:

async function getFoo(): Promise<Foo> {
    const foo: Json<Foo> = await get('https://example.com/foo');
    return {
        ...foo,
        date: new Date(foo.date)
    };
}

Basically, the upshot of having the type Json<T> is that it is now not possible to forget to supply the mapping from Json<Foo> to Foo because in this case a Json<Foo> is not directly assignable to Foo due to the Json<Foo> knowing that the date property is actually a string in the Json payload. Furthermore, there is no longer a need to mandate that a DTO type be created for every object (or every object that contains a Date property). This leads to code that is therefore less prone to errors.

From our perspective it would therefore seem to make sense if something along these lines was incorporated into lib.d.ts. Our suggestion is therefore to add the Json<T> type definition and update the json() method to have the signature json<T>(): Promise<Json<T>>.

However, we obviously realise that there might be some reservations with this implementation. The main points that spring to mind are:

  1. Can we assume that all dates are represented as a string in a JSON payload? This certainly seems like the most common representation, but I'm sure there will be others out in the wild.
  2. Are there other complex types like Date that suffer from this problem?
  3. Obviously none of this prevents developers from having type mismatches between the client and server.

To give my two cents on this, I think that the solution proposed does not prohibit someone from just calling json<any>() which would effectively give them the existing behaviour, and in fact for backwards compatibility it would probably make sense to default the generic argument to any so that existing code of that didn't specify the generic argument would still be valid. Essentially, I think that this proposal enhances the types for most cases without regressing things in the other cases.

Anyway, I hope this can at least form the start of something useful that can be included in lib.d.ts to improve the typing of data returned from an API.

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Out of ScopeThis idea sits outside of the TypeScript language design constraintsSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions