TypeScript API Design and Error Handling: A Practical Guide for Stable Frontend and Backend Collaboration
TypeScript API Design and Error Handling: A Practical Guide for Stable Frontend and Backend Collaboration
TypeScript prevents many basic mistakes, but it does not automatically make an API safe. Real projects usually fail around inconsistent response shapes, unclear error handling, missing runtime validation, and weak permission boundaries.
This guide uses content websites and admin dashboards as examples to show how to design TypeScript APIs that are easier to consume, test, monitor, and evolve.
1. Use one response shape
Do not let every endpoint invent its own response format. One endpoint returning { data }, another returning { result }, and another returning a plain string creates unnecessary client-side complexity.
A predictable response model is easier to use:
interface ApiResponse<T = unknown> {
success: boolean
data?: T
error?: {
code: string
message: string
details?: unknown
}
}
Example success:
{
"success": true,
"data": {
"id": "post_123",
"title": "Production Security Checklist"
}
}
Example failure:
{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "You must be signed in to publish posts."
}
}
This makes frontend handling much simpler.
2. Separate validation errors from system errors
A user entering an invalid email is not the same as a database outage. Treat them differently.
Useful error categories:
VALIDATION_ERRORUNAUTHORIZEDFORBIDDENNOT_FOUNDCONFLICTRATE_LIMITEDINTERNAL_ERROR
The frontend can then show the right user experience:
- Validation errors appear near form fields.
- Unauthorized errors redirect to login.
- Forbidden errors explain missing permissions.
- Internal errors show a friendly retry message.
3. Runtime validation is still required
TypeScript checks code at build time. It does not validate JSON sent by a browser, webhook, bot, or third-party service.
Every mutation endpoint should validate input at runtime:
type CreatePostInput = {
title: string
slug: string
content: string
category: string
tags: string[]
}
function validateCreatePost(input: unknown): CreatePostInput {
if (!input || typeof input !== 'object') {
throw new Error('Request body must be an object')
}
const value = input as Record<string, unknown>
if (typeof value.title !== 'string' || value.title.trim().length < 5) {
throw new Error('Title must be at least 5 characters')
}
if (typeof value.slug !== 'string' || !/^[a-z0-9-]+$/.test(value.slug)) {
throw new Error('Slug must contain only lowercase letters, numbers, and hyphens')
}
return value as CreatePostInput
}
Libraries such as Zod, Valibot, or ArkType can make this cleaner, but the principle is the same: never trust incoming JSON.
4. Keep permission checks close to mutations
Permission checks should not live only in the UI. A hidden button is not security.
For admin actions, verify the user on the server:
async function requireAdmin(request: Request, env: Env) {
const user = await getCurrentUser(request, env)
if (!user) {
throw createApiError('UNAUTHORIZED', 'Sign in required')
}
if (user.role !== 'admin') {
throw createApiError('FORBIDDEN', 'Admin access required')
}
return user
}
Call this inside every protected write route.
5. Design client wrappers around the API contract
Instead of scattering fetch() calls across components, create a small API client:
async function apiFetch<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, {
...init,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
})
const body = (await response.json()) as ApiResponse<T>
if (!response.ok || !body.success) {
throw new ApiClientError(
body.error?.code ?? 'REQUEST_FAILED',
body.error?.message ?? 'Request failed'
)
}
return body.data as T
}
This gives components a clean contract:
const post = await apiFetch<Post>('/api/posts/my-slug')
6. Do not leak internal error details
Server logs should contain details. Public API responses should not expose stack traces, SQL statements, raw tokens, file paths, or internal service names.
Bad response:
{
"success": false,
"error": "SQLITE_CONSTRAINT: UNIQUE constraint failed: users.email at /functions/api/auth/register.ts:92"
}
Better response:
{
"success": false,
"error": {
"code": "CONFLICT",
"message": "This email is already registered."
}
}
Log the internal error separately with request IDs so it can be investigated.
7. Make pagination explicit
List endpoints should not return unlimited data.
Use explicit pagination:
interface PaginatedResult<T> {
items: T[]
page: number
pageSize: number
total: number
hasNextPage: boolean
}
For high-volume systems, cursor pagination may be better:
interface CursorResult<T> {
items: T[]
nextCursor?: string
}
The important part is that the contract is stable and documented.
8. Version behavior, not just URLs
You do not always need /v1 and /v2 paths. But you do need to treat API changes carefully.
Breaking changes include:
- Removing fields.
- Changing field types.
- Changing error codes.
- Changing pagination behavior.
- Changing authentication requirements.
Prefer additive changes. Add new fields before removing old ones. Keep old fields until clients have migrated.
9. Test the unhappy paths
API tests often cover only success cases. Production bugs often live in failure paths.
Test:
- Missing authentication.
- Wrong role.
- Invalid input.
- Duplicate slugs.
- Missing records.
- Database errors.
- Malformed JSON.
- Rate-limited requests.
If error handling is tested, the frontend can handle failures with confidence.
Conclusion
Good TypeScript API design is about predictable contracts. Use one response shape, validate inputs at runtime, categorize errors, enforce permissions on the server, wrap client requests, and avoid leaking internals.
When frontend and backend share a stable API model, teams move faster because every feature has fewer hidden assumptions.
Comments
Share your thoughts and join the discussion
Comments (0)
Related Articles
TypeScript Advanced Type System: Mastering Generics and Type Gymnastics
Deep dive into TypeScript advanced types including generics, conditional types, mapped types, template literals, and type gymnastics. Learn practical techniques for building type-safe applications with real-world examples and best practices.
TypeScript Advanced Types: Practical Patterns for Safer Application Code
Learn practical TypeScript advanced type patterns, including generics, constraints, conditional types, mapped types, discriminated unions, utility types, and API response modeling.
React 19 Complete Guide: Revolutionary Features and Best Practices
Explore React 19 groundbreaking features including Server Actions, Transitions API, new Hooks (use, useOptimistic, useFormStatus), and automatic optimizations. Learn how to build modern, high-performance React applications with practical code examples.
Please or to comment