Typing Requests
Supply a request's response type in its builder, using withResponseType. The type becomes part of the builder's contract, and every caller gets it through inference.
import type { ReactiveDataDocument } from '@warp-drive/core/reactive';
import { withResponseType } from '@warp-drive/core/request';
interface User {
id: string;
firstName: string;
lastName: string;
}
/**
* Gets a user by id.
*/
export function getUser(id: string) {
return withResponseType<ReactiveDataDocument<User>>({
url: `/users/${id}`
});
}
const result = await store.request(getUser('1'));
result.content.data.firstName; // will have type stringA builder is the only way to type a request without a cast. Calling withResponseType inline where the request is made, or writing as on the result, asserts a type for a request that the caller didn't define. See Typed Requests Without Casting.
The same builder types the Component API, including in templating syntaxes that don't allow TypeScript generics:
import { Request } from '@warp-drive/ember';
import { getUser } from '#/builders/get-user.ts';
export default <template>
<Request @query={{getUser @userId}}>
<:content as |result|>
<h1>Hello {{result.data.firstName}}!</h1>
</:content>
</Request>
</template>;Typing Reactive Responses
Requests that return reactive responses wrap the primary resource data in a ReactiveDocument. In the case of an error, this will be a ReactiveErrorDocument and in the case of success it will be a ReactiveDataDocument
A convenient utility is available for typing these reactive responses.
import { withReactiveResponse } from '@warp-drive/core/request';
import type { User } from '#/data/user';
function getUsers() {
return withReactiveResponse<User[]>({
url: '/users'
})
);The equivalent using withResponseType and ReactiveDataDocument is below.
import type { ReactiveDataDocument } from '@warp-drive/core/reactive';
import { withResponseType } from '@warp-drive/core/request';
import type { User } from '#/data/user';
function getUsers() {
return withResponseType<ReactiveDataDocument<User[]>>({
url: '/users'
})
);Typing the Document's meta
A reactive document also carries the response's meta. By default it is typed Meta — an arbitrary JSON object — so reading a key off it gives you unknown:
const { content } = await store.request(getUsers());
Number(content.meta?.total ?? 0); // meta.total is unknownBoth ReactiveDataDocument and withReactiveResponse take an optional second type param for the meta. Supplying it also makes meta non-optional, so you stop writing ?. for a key you just declared. Leave it off and meta stays optional, which is what keeps a document type derivable — an interface that re-declares meta?:, an intersection with { meta?: X }, or a class ... implements all continue to work:
import { withReactiveResponse } from '@warp-drive/core/request';
import type { User } from '#/data/user';
type PageMeta = {
page: { limit: number; offset: number };
total?: number;
};
function getUsers() {
return withReactiveResponse<User[], PageMeta>({
url: '/users'
});
}
const { content } = await store.request(getUsers());
content.meta.total; // numberNOTE: The param must be a type alias rather than an interface. This is because Meta is an index-signature type, and TypeScript gives implicit index signatures to aliases only, so an interface will not satisfy the M extends Meta | undefined constraint.
Pass PageMeta | undefined instead if the endpoint only sometimes returns its meta — the default, Meta | undefined, is that same shape with no keys named.
The builders in @warp-drive/utilities take the same param, so a builder can declare its own meta without hand-writing the document type:
import { query } from '@warp-drive/utilities/json-api';
import type { User } from '#/data/user';
const options = query<User, PageMeta>('user', { page: { limit: 10 } });
const { content } = await store.request(options);
content.meta.page.limit; // numbernext, prev, first, last and fetch carry the same meta type through to the document they resolve with, since they hit the same endpoint.
An endpoint that returns only meta and no primary data — a count, for instance — has no resource type to name. Pass never for the first param:
function getUserCount() {
return withReactiveResponse<never, { total: number }>({ url: '/users/count' });
}
const { content } = await store.request(getUserCount());
content.meta.total; // numberTyping Errors
The error variant of a document, ReactiveErrorDocument, exposes errors. The cache stores whatever the API sent without validating it, so by default the type promises no shape — errors is object[].
A third type param declares what the endpoint actually returns. The builders in @warp-drive/utilities/json-api default it to ApiError, the {json:api} error object:
import { query } from '@warp-drive/utilities/json-api';
const { content } = await store.request(query<User>('user'));
const nextPage = await content.next(); // resolves with the document union
if (nextPage?.errors) {
nextPage.errors[0].status; // string | undefined
nextPage.errors[0].source?.pointer; // string | undefined
}The rest and active-record builders leave it as object, since neither REST nor ActiveRecord specifies an error shape. Supply your own when you know it:
type MyError = { code: string; message: string };
function getUsers() {
return withReactiveResponse<User[], PageMeta, MyError>({ url: '/users' });
}Typing the Error Document's meta
Because error responses may return different meta than successful responses, you can pass an optional fourth type param to type the error document's meta separately (otherwise it will fall back to the same meta type as the success response):
type PageMeta = { page: { limit: number; offset: number } };
type ErrorMeta = { requestId: string };
type UsersDocument = ReactiveDataDocument<User[], PageMeta, ApiError, ErrorMeta>;
type UsersErrorDocument = ReactiveErrorDocument<User[], ErrorMeta, ApiError>;You can access the error document through getRequestState, which takes the error content type as an optional second param:
const future = store.request<UsersDocument>({ url: '/users' });
const state = getRequestState<UsersDocument, UsersErrorDocument>(future);
if (state.isError) {
state.reason.content?.meta.requestId; // string | undefined
state.reason.content?.errors[0].status; // string | undefined
}How it works (for the curious)
requestManager.request and store.request each take a generic that can be used to set the return type of the content of the associated request.
interface Store {
request<RT>(requestInit: RequestInfo<RT>): Future<RT>;
}The requestInit param shares use of this generic, and its RequestInfo type assigns its generic own arg to a special brand:
interface RequestInfo<RT> {
[RequestSignature]: RT;
}What this means is that any requestInfo param using this brand in its type will enable the request method to infer the attached response signature due to the shared generic. withResponseType adds this brand into your object's type in a convenient way.
Why This Approach?
Typing the response to a network request is inherently frail. Even in the best designed, integrated and tested systems the contract may drift.
By using a simple generic to enable providing the response type, we provide maximum flexibility for apps to choose their own level of safety.
You might handroll types, or you might have intelligent tooling that constructs the type from the request (like GraphQL), or even tooling that compiles the type from your API specs. Perhaps you love libraries like ArkType, Valibot, or Zod.
Each of these comes with its own tradeoffs, but each can be made to provide this generic quite easily. Instead of selecting a tradeoff you probably would hate, we have left that choice for you. This said, we recommend starting with builders and then exploring more advanced setups only if you still need once you've gotten your feet wet.