Практика: типизация API в React
Собрали подходы к типизации API-запросов в React-приложениях.
Типы данных
type User = {
id: number;
name: string;
email: string;
createdAt: string;
};
type UserInput = Omit<User, 'id' | 'createdAt'>;
type UserUpdate = Partial<UserInput>;
API-клиент
const API_URL = import.meta.env.VITE_API_URL;
type ApiError = {
message: string;
errors?: Record<string, string[]>;
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
...init,
});
if (!res.ok) {
const error: ApiError = await res.json();
throw new Error(error.message);
}
return res.json() as Promise<T>;
}
CRUD
export const usersApi = {
list: (page = 1) => request<Paginated<User>>(`/api/users?page=${page}`),
get: (id: number) => request<User>(`/api/users/${id}`),
create: (data: UserInput) =>
request<User>('/api/users', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: number, data: UserUpdate) =>
request<User>(`/api/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
remove: (id: number) =>
request<void>(`/api/users/${id}`, { method: 'DELETE' }),
};
Paginated
type Paginated<T> = {
data: T[];
meta: {
current_page: number;
last_page: number;
per_page: number;
total: number;
};
};
Хук useQuery
function useUsers(page: number) {
const [data, setData] = useState<Paginated<User> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
usersApi.list(page)
.then((res) => { if (!cancelled) setData(res); })
.catch((e) => { if (!cancelled) setError(e as Error); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [page]);
return { data, loading, error };
}
TanStack Query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function useUsers(page: number) {
return useQuery({
queryKey: ['users', page],
queryFn: () => usersApi.list(page),
});
}
function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UserInput) => usersApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
TypeScript выводит data как Paginated<User> | undefined, error как Error | null.
Типизированные ключи
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (page: number) => [...userKeys.lists(), { page }] as const,
detail: (id: number) => [...userKeys.all, 'detail', id] as const,
};
Инвалидация:
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
Обработка ошибок
function UserForm() {
const { mutate, error } = useCreateUser();
function onSubmit(data: UserInput) {
mutate(data, {
onError: (err: Error) => console.error(err.message),
});
}
}
Response union
type ApiResponse<T> =
| { ok: true; data: T }
| { ok: false; error: ApiError };
async function safeRequest<T>(path: string): Promise<ApiResponse<T>> {
const res = await fetch(path);
if (!res.ok) {
return { ok: false, error: await res.json() };
}
return { ok: true, data: await res.json() };
}
const res = await safeRequest<User>('/api/users/1');
if (res.ok) {
console.log(res.data.name);
} else {
console.log(res.error.message);
}
TypeScript сужает по ok.
Zod для валидации
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string(),
});
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return UserSchema.parse(await res.json());
}
Runtime-валидация + типы из схемы.
Итоги
- Типы данных с
Omit,Partial - API-клиент с дженериком
request<T> Paginated<T>для списков- TanStack Query выводит типы
- Типизированные ключи для инвалидации
- Zod для runtime-валидации