TypeSafe Fetch Handler
- typescript
- http
- DX
- client-server
- request handlers
- endpoints
9 min read 1823 words
import { z } from 'zod';
import { ClientFetcher } from '$lib/http/client.svelte';
import { langs } from './lang.service.common';
import type { RouteId } from './$types';
export const postReqSchema = z.object({
lang: z.enum(langs),
excludeColor: z.string(),
simulateDelay: z.boolean().optional(),
});
export type PostReq = z.infer<typeof postReqSchema>;
export type PostRes = { color: string };
export const getRandomColor = new ClientFetcher<RouteId, PostRes, PostReq>(
'POST',
'/articles/typesafe-fetch-handler/demos/main',
);
import { jsonFail, jsonOk } from '$lib/http/server';
import { getNewColor } from './lang.service.server';
import { postReqSchema, type PostRes } from './index';
import type { RequestHandler } from '@sveltejs/kit';
const getRandomColor: RequestHandler = async ({ request }) => {
const verifiedBody = postReqSchema.safeParse(await request.json().catch(() => null));
if (!verifiedBody.success) return jsonFail(400);
const { excludeColor, lang, simulateDelay } = verifiedBody.data;
const newColor = getNewColor({ lang, excludeColor });
if (simulateDelay) {
return new Promise((r) => setTimeout(r, 5000)).then(() => {
return jsonOk<PostRes>({ color: newColor });
});
}
return jsonOk<PostRes>({ color: newColor });
};
export const POST: RequestHandler = getRandomColor;
<script lang="ts">
import { fly } from 'svelte/transition';
import { Switch } from '$lib/components';
import { LoadingDots } from '$lib/components';
import I from '$lib/icons';
import { TempValue } from '$lib/stores';
import LangSelect from './LangSelect.svelte';
import { type Lang, langOptions } from './lang.service.common';
import { getRandomColor as get } from './index';
let currentColor = $state('Yellow');
let simulateDelay = $state(false);
let time = $state('');
let abortController: AbortController | null = $state(null);
let res: null | Awaited<ReturnType<(typeof get)['send']>> = $state(null);
let selectedLang: Lang = $state(langOptions.language[0].value.lang);
const success = new TempValue<true>({ duration: 1500 });
const getColor = async () => {
if (selectedLang === undefined) return;
if (get.submitting) return;
success.value = null;
time = '';
const start = performance.now();
abortController = new AbortController();
res = null;
res = await get.send(
{ excludeColor: currentColor, lang: selectedLang, simulateDelay },
{ delayMs: 150, timeoutMs: 3000, abortSignal: abortController.signal },
);
abortController = null;
const perf = performance.now() - start;
time = perf.toFixed(0);
if (res.error) return;
if (perf > 3000) success.value = true;
currentColor = res.data.color;
};
</script>
<div class="grid gap-x-12 gap-y-8 p-8 lg:grid-cols-2">
<div class="grid w-72 items-center gap-4">
<LangSelect onSelect={(lang) => (selectedLang = lang)} />
<button class="btn btn-accent transition-colors {get.delayed ? 'cursor-not-allowed' : ''}" onclick={getColor}>
{#if get.timeout}
<span class="grid w-[1.625rem] place-content-center" aria-label="Taking longer than usual.">
<LoadingDots class="bg-accent-1" wrapperClasses="mx-0" />
</span>
{:else if get.delayed}
<span class="grid w-[1.625rem] place-content-center" aria-label="Delayed">
<I.LoaderCircle class="inline h-5 w-5 animate-spin" />
</span>
{:else if success.value}
<span
in:fly={{ y: '4px', duration: 400 }}
class="grid w-[1.625rem] place-content-center"
aria-label="Succeeded"
>
<I.Check class="inline h-5 w-5" />
</span>
{:else}
<span in:fly={{ y: '4px', duration: 400 }} class="w-[1.625rem]">Get</span>
{/if}
Random Color
</button>
<button class="btn btn-hollow w-full" disabled={!get.delayed} onclick={() => abortController?.abort()}>
Abort Request
</button>
<span class="flex items-center justify-between gap-2">
<label for="delay-switch">Simulate Delay</label>
<Switch state={simulateDelay} onClick={() => (simulateDelay = !simulateDelay)} id="delay-switch" />
</span>
</div>
<div class="grid h-full w-72 grid-cols-2 flex-col flex-wrap justify-between text-lg lg:flex">
<span>Color: {currentColor}</span>
<div>Loading: {get.submitting}</div>
<div>Delayed: {get.delayed}</div>
<div>Timeout: {get.timeout}</div>
<div class="col-span-2">Performance: {time ? `${time}ms` : ''}</div>
<div class="col-span-2">Data: {JSON.stringify(res?.data ?? '')}</div>
<div class="col-span-2">Error: {JSON.stringify(res?.error ?? '')}</div>
</div>
</div>
<script lang="ts">
import I from '$lib/icons';
import { getRandomColor as get } from '$routes/articles/typesafe-fetch-handler/demos/main';
import LangSelect from './LangSelect.svelte';
import { defaultLang, type Lang } from './lang.service.common';
let currentColor = $state('Yellow');
let selectedLang: Lang = $state(defaultLang.value.lang);
const getColor = async () => {
if (get.submitting || !selectedLang) return;
const { data, error } = await get.send({ excludeColor: currentColor, lang: selectedLang });
if (error) return window.alert('How did you break this? 😭');
currentColor = data.color;
};
</script>
<div class="grid gap-x-12 gap-y-8 p-16">
<div class="col-span-2 sm:col-span-1">
<LangSelect onSelect={(lang) => (selectedLang = lang)} />
</div>
<button
class="btn btn-accent col-span-2 transition-colors sm:col-span-1 {get.delayed ? 'cursor-not-allowed' : ''}"
onclick={getColor}
>
{#if get.delayed}
<span class="grid w-[1.625rem] place-content-center" aria-label="Delayed">
<I.LoaderCircle class="inline h-5 w-5 animate-spin" />
</span>
{:else}
<span class="w-[1.625rem]">Get</span>
{/if}
Random Color
</button>
<span class="col-span-2 text-center text-lg">Color: {currentColor}</span>
</div>
Data in SvelteKit
There are three ways to breach the client/server divide in SvelteKit:
0.1 | Component Initialization | Load Functions |
0.2 | Form Submissions | Form Actions |
0.3 | HTTP Requests | Request Handlers |
Pop quiz! Which are typed by SvelteKit?
Let's have a quick look at each of the three ways to communicate between client and server to find our answer.
Load Functions
SvelteKit does the heavy lifting here. We return data and the client automatically has full type safety. It works because SvelteKit writes PageData
to a $types.d.ts
file in the .svelte-kit
folder. That folder is
included by .svelte-kit/tsconfig.json
which is itself included by your app's tsconfig.json
.
export const load: PageServerLoad = async () => {
return { name: 'Bart Simpson' as const }
}
<script lang="ts">
const { data } = $props(); // (property) name: "Bart Simpson"
</script>
Form Actions
PageData
, ActionData
is typed the same way and applies to const { form } = $props();
There are also packages which expand on this with more features and the ability to easily handle multiple forms on a single page. My favorite is SuperForms.
const verifySMSTokenForm = await superValidate(request, zod(verifyOTPSchema));
if (!verifySMSTokenForm.valid) {
return message(verifySMSTokenForm, { fail: 'Invalid digits' }, { status: 400 });
}
Endpoints
By now you probably already know the answer to our little quiz.
It's unfortunate that SvelteKit doesn't provide automatic typings for the endpoints, but its architecture makes it exceptionally easy to add this feature yourself. We'll focus our attention here and build a simple wrapper so our endpoints are as joyful to use as load functions and form actions.
We'll want to change this sad and fraught code:
<script lang="ts">
let loading = false;
const loadNames = async () => {
// Manually set loading indicator
loading = true;
// Pretty sure I typed the route correctly.
const someRes = await fetch('/api/add-name', {
method: 'POST', // Or was it PUT?
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Tim' }), // Was it name or firstName???
});
if (someRes.ok) {
const data = await someRes.json();
// The api returns the new list of names... right?
if (data.allNames) names = data.allNames
} else {
console.error('yikes! I miss TypeScript and IntelliSense!')
}
loading = false
}
</script>
{#if loading}
<p>Adding name...</p>
{/if}
to this:
<script lang="ts">
import { addNameToList } from '$routes/demos/name-list.json';
const loadNames = async () => {
// fully typed body. no route or method to remember
const { data, error } = await addNameToList.send({ name: 'Tim' });
if (error) handleError();
else names = data.allNames; // fully typed. no response type to look up
}
</script>
{#if addNameToList.submitting}
<p>Adding name...</p>
{/if}
Thinking through the API
Interface
As we saw above we know we'll need a send function and submitting state.
interface Fetcher<ResponseData, RequestData = void> {
submitting: boolean;
send: (body: RequestData) => Promise<Result<ResponseData>>;
}
With respect for function signatures and healthy fear of undocumented code paths, the send function is designed to
return errors instead of throwing them. The Result
type looks like this:
export type Result<T, E = App.JSONError> = NonNullable<Result.Err<E> | Result.Ok<T>>;
export namespace Result {
export type Success = { message: 'Success' };
export type Ok<T> = { data: T; error?: never };
export type Err<E> = { data?: never; error: E };
}
If we always return this type from our fetch handler, we can always destructure { data, error }
. Verifying the existence of one will make TS know the other is undefined
.
Superforms has a few nice features that are worth copying over to our fetch API. submitting
switches to true
as soon as the request is sent. As Superforms explains, it's
often better to wait a bit before showing the loading indicator. This is especially true for fast requests, where the
loading indicator can be more distracting than helpful. With that in mind, let's imitate their delayed
and timeout
stores.
interface Fetcher<ResponseData, RequestData = void> {
submitting: boolean;
delayed: boolean;
timeout: boolean;
send: (body: RequestData) => Promise<Result<ResponseData>>;
}
Server / Client Separation
Server endpoint handlers and client fetch wrappers should be co-located, but fully distinct and impossible to mix up.
Let's declare that every endpoint has a *.json
folder. Inside is a +server.ts
with RequestHandler
s and an index.ts
file with types and client fetch wrappers.
It's already easy to understand what's going on because the files are side by side, but for uniformity and ease of search, I name the server endpoint handler and client fetch wrapper the same thing. Explicit naming instead of lambdas serves as a form of documentation.
const addNameToList = async ({ locals, request }: RequestEvent) => { ... };
export const PUT: RequestHandler = addNameToList;
type PutReq = { name: string };
export type PutRes = { allNames: string[] };
export const addNameToList = new ClientFetcher();
Hiding the route / method / types
The caller shouldn't need to know the route or the method. That should be defined by the wrapper. Also, the body and
response should be fully typed, which means ClientFetcher
will have to be generic.
SvelteKit even exports a RouteId
type, which means we can make it so that if we rename the folder
containing our endpoint, we get a compile error!
Love ya SvelteKit!
import type { RouteId } from './$types';
type PutReq = { name: string };
export type PutRes = { allNames: string[] };
export const addNameToList = new ClientFetcher();
export const addNameToList = new ClientFetcher<RouteId, PutRes, PutReq>('PUT', '/demos/name-list.json');
Enforce server / client type consistency
How do we enforce that we actually return the type we say we're returning from the endpoint? We need to ensure we
always return the correct Result
type from our endpoint handlers. Let's create two helpers to make it
easy: jsonOk
and jsonFail
. As a bonus, we'll make sure the error inherits the type from App.JSONError
so we have a single source of truth for error types.
declare global {
namespace App {
interface Error {
message: string;
}
type JSONError = App.Error & { status: number };
...
import { json } from '@sveltejs/kit';
import { httpCodeMap } from '../common';
export const jsonOk = <T extends Record<string, unknown>>(body?: T) =>
body ? json({ data: body }) : json({ data: { message: 'Success' } });
export function jsonFail(status: 400 | 401 | 403 | 404 | 429 | 500): Response;
export function jsonFail(status: number, message: string): Response;
export function jsonFail(status: number, message?: string): Response {
let e: { error: App.JSONError };
if (message) e = { error: { message, status } };
else e = { error: { message: httpCodeMap[status] ?? 'Unknown Error', status } };
return json(e, { status });
}
export const httpCodeMap: Record<number, string> = {
200: 'Success',
202: 'Success',
400: 'Bad request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not found',
429: 'Too Many Requests',
500: 'Internal server error',
};
export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
Back in our endpoint, we can now use our types and functions to make the endpoint handler fully typed and consistent.
const addNameToList = async ({ locals, request }: RequestEvent) => {
return jsonFail(403);
// or
return jsonOk<GetRes>(res);
};
export const PUT: RequestHandler = addNameToList;
Dynamic Routes
We're almost done designing the API. However, we've so far assumed the route is static. What if it's /demos/[id]/name
instead? We can make another flavor of ClientFetcher
that takes a function
to create the url. Let's call it DynClientFetcher
.
type PutReq = { name: string };
export type PutRes = { allNames: string[] };
export const addNameToList = new DynClientFetcher<PutRes, PutReq, { id: string }>('PUT', ({ id }) => `/demos/${id}/name.json`);
Options
The final consideration is to accept some control options.
type LocalOpts = {
invalidate?: boolean;
preventDuplicateRequests?: boolean;
delayMs?: number;
timeoutMs?: number;
abortSignal?: AbortSignal;
};
type GlobalOpts = { invalidate?: true; preventDuplicateRequests?: true };
interface Fetcher<ResponseData, RequestData = void> {
submitting: boolean;
delayed: boolean;
timeout: boolean;
send: (body: RequestData, localOpts?: LocalOpts) => Promise<Result<ResponseData>>;
}
Implementation
We're finally ready to implement.
import { browser } from '$app/environment';
import { goto, invalidateAll } from '$app/navigation';
import { logger } from '$lib/logging/client';
import type { Result } from '$lib/utils/common';
import type { Method } from './common';
const stripGroup = (str: string) => str.replace(/\/\(.+\)/, '');
type LocalOpts = {
invalidate?: boolean;
preventDuplicateRequests?: boolean;
delayMs?: number;
timeoutMs?: number;
abortSignal?: AbortSignal;
};
type GlobalOpts = { invalidate?: true; preventDuplicateRequests?: true };
function createFetcher<RequestData, ResponseData>(
mut_state: { submitting: boolean; delayed: boolean; timeout: boolean },
method: Method,
) {
return async (url: string, body: RequestData, opts?: LocalOpts) => {
if (!browser) return { error: { status: 500, message: 'Client fetch called on server' } } as Result<ResponseData>;
if (opts?.preventDuplicateRequests && mut_state.submitting)
return { error: { status: 429, message: 'Too Many Requests' } } as Result<ResponseData>;
mut_state.submitting = true;
const delayTimeout = setTimeout(() => (mut_state.delayed = true), opts?.delayMs ?? 500);
const timeoutTimeout = setTimeout(() => (mut_state.timeout = true), opts?.timeoutMs ?? 10000);
const finish = () => {
clearTimeout(delayTimeout);
clearTimeout(timeoutTimeout);
mut_state.submitting = false;
mut_state.delayed = false;
mut_state.timeout = false;
};
let cleanBody;
try {
cleanBody = body ? JSON.stringify(body) : undefined;
} catch (err) {
logger.error(err);
return { error: { status: 400, message: 'Unable to stringify body' } } as Result<ResponseData>;
}
try {
const res = await fetch(stripGroup(url), { signal: opts?.abortSignal, method, body: cleanBody });
if (res.redirected) {
finish();
await goto(res.url);
return { error: { status: 302, message: 'Redirected' } } as Result<ResponseData>;
} else {
const json = await res.json();
if (opts?.invalidate) await invalidateAll();
finish();
return json as Result<ResponseData>;
}
} catch (err) {
finish();
if ((err instanceof DOMException && err.name === 'AbortError') || (err as { status: number })?.status === 499) {
return { error: { status: 499, message: 'The user aborted the request.' } } as Result<ResponseData>;
}
logger.error(err);
return { error: { status: 500, message: 'Internal Error' } } as Result<ResponseData>;
}
};
}
export class ClientFetcher<RouteId extends string, ResponseData, RequestData = void> {
#boxed = $state({ submitting: false, delayed: false, timeout: false });
#send: (body: RequestData, localOpts?: LocalOpts) => Promise<Result<ResponseData>>;
constructor(method: Method, staticUrl: RouteId, globalOpts?: GlobalOpts) {
const fetcher = createFetcher<RequestData, ResponseData>(this.#boxed, method);
this.#send = async (body, localOpts) => fetcher(staticUrl, body, { ...globalOpts, ...localOpts });
}
get submitting() {
return this.#boxed.submitting;
}
get delayed() {
return this.#boxed.delayed;
}
get timeout() {
return this.#boxed.timeout;
}
get send() {
return this.#send;
}
}
export class DynClientFetcher<ResponseData, RequestData = void, URLProps = void> {
#boxed = $state({ submitting: false, delayed: false, timeout: false });
#sendUrl: (urlProps: URLProps, body: RequestData, localOpts?: LocalOpts) => Promise<Result<ResponseData>>;
constructor(method: Method, urlCreator: (data: URLProps) => string, globalOpts?: GlobalOpts) {
const fetcher = createFetcher<RequestData, ResponseData>(this.#boxed, method);
this.#sendUrl = async (urlProps, body, localOpts) =>
fetcher(urlCreator(urlProps), body, { ...globalOpts, ...localOpts });
}
get submitting() {
return this.#boxed.submitting;
}
get delayed() {
return this.#boxed.delayed;
}
get timeout() {
return this.#boxed.timeout;
}
get sendUrl() {
return this.#sendUrl;
}
}
Conclusion
I really enjoy working with this API. With only around a hundred lines of code, loading states are baked in, errors are accounted for, and type / route / method checks are all moved to compile time. Do you use something similar? Have you found an even better way? Share it in the GitHub discussions!