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