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',
);

Data in SvelteKit

There are three ways to breach the client/server divide in SvelteKit:

0.1Component InitializationLoad Functions
0.2Form SubmissionsForm Actions
0.3HTTP RequestsRequest 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

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

$lib/utils/common/types/result.ts
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 RequestHandlers 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.

$routes/demos/name-list.json/+server.ts
const addNameToList = async ({ locals, request }: RequestEvent) => { ... };
export const PUT: RequestHandler = addNameToList;
$routes/demos/name-list.json/index.ts
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!

$routes/demos/name-list.json/index.ts
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.

app.d.ts
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 });
}

Back in our endpoint, we can now use our types and functions to make the endpoint handler fully typed and consistent.

$routes/demos/name-list.json/+server.ts
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.

$routes/demos/[id]/name/index.ts
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.

$lib/http/client.svelte.ts
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!

Published
Last Updated

Previous Article


Theme Controller

A theme controller that uses CSS variables to control light/dark mode with multiple themes, saves user preference to Cookies, and avoids flashes of unstyled content.
Updated: August 13, 2024

Changelog

  • Update to runes.

Have a suggestion? File an issue.