Skip to main content

TypeScript with SWR: Type Safety Guide

SWR supports TypeScript through generic type parameters, but type inference requires careful typing of your fetcher. Unlike RTK Query, which generates types automatically, SWR leaves the burden on you: correctly type the fetcher, annotate useSWR's generics, and ensure cache keys and fetcher args align. This guide covers typing fetchers, responses, and SWR hooks for production-ready type safety without sacrificing developer experience.

Typing the Fetcher

A fetcher is a function that accepts a URL (or key) and returns a promise of typed data. Start by defining response types:

interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}

interface Post {
id: number;
title: string;
content: string;
authorId: number;
}

// Basic fetcher: URL → JSON
const fetcher = (url: string): Promise<unknown> =>
fetch(url).then((res) => res.json());

// Typed fetcher
const fetcher = async (url: string): Promise<any> => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error('Failed to fetch');
(error as any).status = res.status;
throw error;
}
return res.json();
};

The issue: you've declared the return type as any, losing TypeScript benefits. Instead, create a generic fetcher:

async function fetcher<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) {
const error = new Error(`HTTP ${res.status}`);
(error as any).status = res.status;
throw error;
}
return res.json() as Promise<T>;
}

// Usage in useSWR
const { data } = useSWR<User>('/api/user', () => fetcher<User>('/api/user'));

This is verbose. A cleaner approach: bind the URL to the fetcher at the call site:

const fetcherFactory =
<T,>(url: string) =>
(): Promise<T> =>
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});

// Usage:
const { data } = useSWR<User>('/api/user', fetcherFactory<User>('/api/user'));

Still awkward. The best pattern: use a wrapper that combines key and type:

interface SWRKey<T = unknown> extends Array<unknown> {
0: string; // URL
1: () => Promise<T>;
}

function createSWRKey<T>(url: string, type?: () => T): SWRKey<T> {
return [url, async () => fetch(url).then((r) => r.json())];
}

// Usage:
const userKey = createSWRKey<User>('/api/users/1');
const { data } = useSWR(...userKey);
// data: User | undefined

Basic Type Annotations

For most cases, annotate useSWR directly:

const { data, error, isLoading } = useSWR<User>(
'/api/users/1',
fetcher
);
// data: User | undefined
// error: Error | undefined

SWR's generic signature is useSWR<Data, Error>(key, fetcher). If you omit types, TypeScript defaults to unknown.

Typed Fetchers with Headers and Auth

Extend your fetcher to handle authentication and custom headers:

interface FetchOptions {
headers?: Record<string, string>;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
}

async function apiCall<T>(
url: string,
options: FetchOptions = {}
): Promise<T> {
const token = localStorage.getItem('token');
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};

if (token) {
headers.Authorization = `Bearer ${token}`;
}

const res = await fetch(url, {
method: options.method || 'GET',
headers,
});

if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}

return res.json() as Promise<T>;
}

// Usage:
const { data: user } = useSWR<User>(
'/api/users/1',
(url) => apiCall<User>(url)
);

Handling Errors with Error Types

Type errors properly so you can safely access error properties:

interface APIError {
message: string;
status: number;
details?: Record<string, string>;
}

async function apiCall<T>(url: string): Promise<T> {
const res = await fetch(url);

if (!res.ok) {
const errorData: APIError = await res.json();
const error = new Error(errorData.message) as Error & { status: number };
error.status = res.status;
throw error;
}

return res.json() as Promise<T>;
}

export function UserProfile({ userId }: { userId: number }) {
const { data, error, isLoading } = useSWR<User, Error>(
`/api/users/${userId}`,
(url) => apiCall<User>(url)
);

if (isLoading) return <div>Loading...</div>;

if (error) {
const status = (error as any).status || 'unknown';
return <div>Error {status}: {error.message}</div>;
}

return <div>Hello, {data?.name}!</div>;
}

Type the error second generic to enable proper .error typing in your component.

Conditional and Dynamic Keys

Type keys that change based on conditions:

interface SearchParams {
query: string;
page: number;
}

interface SearchResults {
results: User[];
total: number;
}

export function SearchUsers({ query }: { query: string }) {
const [page, setPage] = useState(1);

// Conditionally fetch only if query is non-empty
const key = query
? (['/api/search', { query, page }] as const)
: null;

const { data, isLoading } = useSWR<SearchResults>(
key,
query ? ([url, params]) => apiCall<SearchResults>(url, { body: params }) : null
);

return (
<div>
{isLoading && <div>Searching...</div>}
{data?.results.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}

The key returns null when query is empty, pausing the fetcher. SWR is smart enough to type this correctly.

Typed Mutations with SWR

Since SWR has no built-in mutation hook, create a typed mutation wrapper:

function useMutation<TData, TArgs>(
url: string,
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'POST'
) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

async function trigger(args: TArgs): Promise<TData> {
setIsLoading(true);
setError(null);

try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});

if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}

return (await res.json()) as TData;
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
setError(error);
throw error;
} finally {
setIsLoading(false);
}
}

return { trigger, isLoading, error };
}

// Usage:
const { trigger: createUser, isLoading } = useMutation<User, { name: string; email: string }>(
'/api/users'
);

async function handleSubmit() {
const newUser = await createUser({ name: 'Alice', email: '[email protected]' });
// newUser: User
}

Dependent Requests with Type Safety

Chain requests while maintaining types:

export function UserWithPosts({ userId }: { userId: number }) {
const { data: user } = useSWR<User>(
`/api/users/${userId}`,
fetcher
);

// Only fetch posts after user loads
const { data: posts } = useSWR<Post[]>(
user ? `/api/users/${userId}/posts` : null,
fetcher
);

return (
<div>
{user && <h1>{user.name}</h1>}
{posts && posts.map((p) => <div key={p.id}>{p.title}</div>)}
</div>
);
}

The second useSWR has key type string | null, and SWR's types ensure the fetcher isn't called when key is null.

Advanced: Generic SWR Hook

Create a reusable, typed SWR hook for your app:

function useAPI<T>(
url: string | null,
options?: {
revalidateOnFocus?: boolean;
refreshInterval?: number;
}
) {
return useSWR<T, Error>(
url,
url ? (u) => apiCall<T>(u) : null,
{
revalidateOnFocus: options?.revalidateOnFocus ?? false,
refreshInterval: options?.refreshInterval ?? 0,
}
);
}

// Usage:
const { data: user } = useAPI<User>('/api/users/1');
const { data: posts } = useAPI<Post[]>('/api/posts', {
refreshInterval: 5000,
});

This combines your app's auth logic, error handling, and defaults into a single, typed hook.

Key Takeaways

  • Type fetchers with generics: async function fetcher<T>(url: string): Promise<T>
  • Annotate useSWR<Data, Error>() with response and error types
  • Use generic mutations with <ResponseType, ArgumentType> to keep mutations type-safe
  • Type error objects to safely access properties like .status or .details
  • Create custom hooks (useAPI<T>, useMutation<T, A>) to reduce repetition across your codebase

Frequently Asked Questions

Why does my type inference fail when I chain useSWR calls?

SWR's types narrow based on explicit generics. If you skip types on the second hook, TypeScript doesn't know the key is a string, not string | null. Always annotate dependent hooks:

const { data: user } = useSWR<User>(`/api/users/${userId}`, fetcher);
const { data: posts } = useSWR<Post[]>(
user ? `/api/users/${userId}/posts` : null,
fetcher
);

Can I reuse the same fetcher across different endpoints?

Yes, if you parameterize it:

const fetcher = async <T,>(url: string): Promise<T> => {
return fetch(url).then((r) => r.json());
};

const { data: user } = useSWR<User>('/api/users/1', fetcher);
const { data: posts } = useSWR<Post[]>('/api/posts', fetcher);

How do I type error responses from the server?

Create an error type and cast in your fetcher:

interface APIErrorResponse {
message: string;
code: string;
}

const { error } = useSWR<User, APIErrorResponse>(url, fetcher);
// error: APIErrorResponse | undefined

Do I need as const on SWR keys?

For complex keys (arrays with multiple items), as const helps TypeScript narrow types. For simple URL strings, it's optional:

const key = ['/api/search', { query }] as const;
const { data } = useSWR<Results>(key, fetcher);

Can TypeScript infer the response type from my fetcher?

No. SWR's generics don't automatically extract types from the fetcher function signature. You must annotate useSWR<T>() explicitly.

Further Reading