2
votes

We use JSON:API as the main serialization schema for our API. Without going into the specifics, it mandates JSON responses from a server to have a top-level data property that may contain a single entity or an array of entities:

// Single item
{ "data": { "id": 1 } }

// Collection of items
{ "data": [
    { "id": 1 },
    { "id": 2 }
] }

I encoded this schema into TypeScript types, so we can properly type API responses. This has yielded the following:

// Attributes as the base type for our entities
type Attributes = Record<string, any>;

// Single resource object containing attributes
interface ResourceObject<D extends Attributes> {
    attributes: D;
}

// Collection of resource objects
type Collection<D extends Attributes> = ResourceObject<D>[];

// Resource object OR collection, depending on the type of D
type ResourceObjectOrCollection<D extends Attributes | Attributes[]> = D extends Array<infer E>
    ? Collection<E>
    : ResourceObject<D>;

// A response with a resource object or a collection of items of the same type
interface ApiResponse<T> {
    data: ResourceObjectOrCollection<T>;
}

A response always contains a data property that may be a single resource object, or a collection of resource objects.
A resource object always contains an attributes property that holds arbitrary entity attributes. The purpose of all typing here is to propagate the attributes structure by passing entity interfaces as the generic T, as illustrated by the following examples:

interface Cat {
    name: string;
}

function performSomeApiCall<Ret>(uri: string): Ret {
    return JSON.parse(''); // Stub, obviously
}

function single<T extends Attributes = Attributes>(uri: string): ApiResponse<T> {
    return performSomeApiCall<ApiResponse<T>>(uri);
}

The single function fetches a response from an endpoint that returns a single entity - say, /api/cats/42. Thus, by passing the Cat interface the type of data correctly resolves to a single resource object:

const name: string = single<Cat>('/api/cats/42').data.attributes.name;

We can also define a function that returns multiple entities:

function multiple<T extends Attributes = Attributes>(uri: string): ApiResponse<T[]> {
    return performSomeApiCall<ApiResponse<T[]>>(uri);
}

This function returns a collection of Ts, so the following is valid, too:

const names: string[] = multiple<Cat>('/api/cats').data.map(item => item.attributes.name);

What does not work, however, is defining a function that retrieves the attributes of a single entity directly, and that is what I don't understand:

function singleAttributes<T extends Attributes = Attributes>(uri: string): T {
    return single<T>(uri).data.attributes;
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
}

Type 'Record<string, any>' is not assignable to type 'T'.
'Record<string, any>' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Record<string, any>'. (2322)

Why does Typescript understand the single function returns a resource object for T, but not for singleAttributes? Somewhere, it seems to drop the generic type in favor of the base type of Attributes, but I don't get why or where.

Check out the playground link for a demo of the problem.

1
try to remove explicit return type from singleAttributes. In most cases TS should infer return typecaptain-yossarian
that seems to work, but I'm still curious why T isn't statically valid as the return type?Moritz Friedrich

1 Answers

1
votes

Return type of

function singleAttributes<T extends Attributes = Attributes>(uri: string): T {
    return single<T>(uri).data.attributes; < --- Record<string, any>
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
}

is in fact Record<string, any>

See next example:

function singleAttributes<T extends Attributes>(uri: string): T {
    let ret = single<T>(uri).data.attributes

    let t: T = null as any;
    ret = t; // ok
    t = single<T>(uri).data.attributes // error
}

Type 'Record<string, any>' is not assignable to type 'T'. 'Record<string, any>' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Record<string, any>'.

T is assignable to return value, but return value is not assignable to T. That's why you can use T as an explicit type for return type

T could be much wider type.

Se example:

type WiderType = Record<string | symbol, any> extends Record<string, any> ? true : false // true


type WiderType2 = Record<string, any> extends Record<string | symbol, any> ? true : false // true

When you expect Record<stirng, any>, T can be Record<string | symbol,any> as well. Or even Record<string | symbol | number, any>

In order to make it work

just use wider type from the start:

function singleAttributes<T extends Record<string | symbol | number, any>>(uri: string): T {
    return single<T>(uri).data.attributes
}

or remove explicit return type

I'd say that Record<string, any> is pretty much the same as Record<number, any> because it will be infered to string according to js specification.

Here you have great explanation

This is working as intended and is a result of the stricter checks introduced in #16368. In your example, the IMyFactoryType (or the interface, they're structurally identical) represents a function that is supposed to return exactly typed values of any type that derives from IMyObject, even though the function doesn't actually have any parameters involving T that would allow it to discover what T is and create an appropriate return value. In other words: