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 T
s, 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.
singleAttributes
. In most cases TS should infer return type – captain-yossarianT
isn't statically valid as the return type? – Moritz Friedrich