TypeScript en pratique - 05 - Typer une API REST

Comment typer les requêtes, réponses, erreurs et créer un client API type-safe avec TypeScript.

05 - Typer une API REST

Ce que tu vas apprendre

  • Typer les paramètres, body et query d'une requête
  • Typer les réponses et les erreurs avec des discriminated unions
  • Creer un client API type-safe
  • Partager les types entre serveur et client
  • Generer des types depuis un schema OpenAPI

Prerequisites

Avoir lu l'article sur l'organisation des types et celui sur Zod.


Le problème des APIs non typees

La plupart des développeurs TypeScript typent leur code interne mais laissent les appels API sans typage réel :

typescript// ❌ fetch retourne any — aucun typage
const res = await fetch("/api/users")
const data = await res.json() // type: any

Ou avec un cast fragile :

typescript// ❌ Assertion — tu mens a TypeScript
const users = (await res.json()) as User[]

Si l'API change (un champ renomme, un type modifie), le cast ne détecté rien. Tu decouvres le problème au runtime.

Typer cote serveur avec Elysia

Elysia est un framework Bun qui type les routes de bout en bout :

typescriptimport { Elysia, t } from "elysia"

const app = new Elysia()
  .get("/api/users/:id", async ({ params }) => {
    // params.id est type string automatiquement
    const user = await db.user.findUnique({ where: { id: params.id } })
    if (!user) {
      throw new Error("User not found")
    }
    return user
  }, {
    params: t.Object({
      id: t.String()
    })
  })
  .post("/api/users", async ({ body }) => {
    // body est type selon le schema
    const user = await db.user.create({ data: body })
    return user
  }, {
    body: t.Object({
      name: t.String({ minLength: 1 }),
      email: t.String({ format: "email" }),
      role: t.Union([t.Literal("admin"), t.Literal("user")])
    })
  })

Le schema definit a la fois la validation runtime et le type TypeScript. Le body dans le handler a le type { name: string; email: string; role: "admin" | "user" } sans rien écrire manuellement.

Typer cote serveur avec Express

Express ne type pas les routes par défaut. On ajoute le typage manuellement :

typescriptimport { Request, Response } from "express"
import { z } from "zod"

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "user"]).default("user")
})

type CreateUserBody = z.infer<typeof CreateUserSchema>

app.post("/api/users", async (req: Request, res: Response) => {
  const result = CreateUserSchema.safeParse(req.body)

  if (!result.success) {
    return res.status(400).json({
      success: false as const,
      error: result.error.flatten()
    })
  }

  const user = await db.user.create({ data: result.data })
  return res.status(201).json({
    success: true as const,
    data: user
  })
})

Le schema Zod fait la validation. Le type est infere du schema. Pas de duplication.

Typer les réponses API

Un pattern que j'utilise partout : une enveloppe typee pour les réponses.

typescript// shared/types/api.ts
type ApiSuccess<T> = {
  success: true
  data: T
}

type ApiError = {
  success: false
  error: {
    code: string
    message: string
    details?: unknown
  }
}

type ApiResponse<T> = ApiSuccess<T> | ApiError

C'est une discriminated union sur le champ success. Le consommateur est force de vérifier :

typescriptconst response: ApiResponse<User> = await fetchUser("123")

if (response.success) {
  console.log(response.data.name) // type: User ✅
} else {
  console.log(response.error.message) // type: string ✅
}

Typer les erreurs par status code

On peut aller plus loin en typant les erreurs par code HTTP :

typescripttype ApiErrorMap = {
  400: { code: "VALIDATION_ERROR"; message: string; fields: Record<string, string[]> }
  401: { code: "UNAUTHORIZED"; message: string }
  404: { code: "NOT_FOUND"; message: string; resource: string }
  500: { code: "INTERNAL_ERROR"; message: string }
}

type ApiErrorResponse = {
  [K in keyof ApiErrorMap]: {
    status: K
    error: ApiErrorMap[K]
  }
}[keyof ApiErrorMap]

Ca donne une union ou chaque erreur est liee a son status code.

Un client API type-safe

Voici le pattern que j'utilise sur paltemps.fr :

typescript// lib/api-client.ts
import { z } from "zod"

async function apiRequest<T>(
  url: string,
  schema: z.ZodType<T>,
  options?: RequestInit
): Promise<T> {
  const res = await fetch(url, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      ...options?.headers,
    },
  })

  if (!res.ok) {
    const error = await res.json().catch(() => ({}))
    throw new ApiError(res.status, error)
  }

  const json = await res.json()
  return schema.parse(json)
}

Chaque appel valide la réponse avec un schema Zod :

typescript// api/users.ts
import { z } from "zod"
import { apiRequest } from "@/lib/api-client"

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string(),
})

const UsersResponseSchema = z.object({
  users: z.array(UserSchema),
  total: z.number(),
})

type User = z.infer<typeof UserSchema>

export async function getUsers(): Promise<{ users: User[]; total: number }> {
  return apiRequest("/api/users", UsersResponseSchema)
}

export async function getUser(id: string): Promise<User> {
  return apiRequest(`/api/users/${id}`, UserSchema)
}

export async function createUser(data: { name: string; email: string }) {
  return apiRequest(`/api/users`, UserSchema, {
    method: "POST",
    body: JSON.stringify(data),
  })
}

Si l'API change son format de réponse, le schema Zod détecté l'incompatibilite immédiatement.

Partager les types serveur/client

Dans un monorepo, les schemas vivent dans un package partage (comme vu dans l'article sur l'organisation des types) :

typescript// packages/shared/src/schemas/user.ts
import { z } from "zod"

export const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "user"]),
  createdAt: z.coerce.date(),
})

export const CreateUserSchema = UserSchema.omit({
  id: true,
  createdAt: true,
})

export type User = z.infer<typeof UserSchema>
export type CreateUserInput = z.infer<typeof CreateUserSchema>

Le backend utilise le schema pour valider les requêtes. Le frontend utilise le schema pour valider les réponses. Les types sont derives du meme schema — une seule source de vérité.

Generer les types depuis OpenAPI

Si tu consommes une API externe qui a un schema OpenAPI/Swagger, généré les types au lieu de les écrire a la main :

bashnpx openapi-typescript https://api.example.com/openapi.json -o src/types/api.ts

Ca généré des types TypeScript depuis le schema :

typescript// Genere automatiquement
export interface paths {
  "/api/users": {
    get: {
      responses: {
        200: {
          content: {
            "application/json": components["schemas"]["UserList"]
          }
        }
      }
    }
    post: {
      requestBody: {
        content: {
          "application/json": components["schemas"]["CreateUser"]
        }
      }
    }
  }
}

Le package openapi-fetch utilise ces types pour créer un client type-safe :

typescriptimport createClient from "openapi-fetch"
import type { paths } from "./types/api"

const client = createClient<paths>({ baseUrl: "https://api.example.com" })

const { data, error } = await client.GET("/api/users", {
  params: { query: { page: 1, limit: 10 } }
})
// data est type automatiquement selon le schema OpenAPI

C'est le niveau maximum de type safety pour les APIs externes. Le schema OpenAPI est la source de vérité — les types TypeScript sont derives.

Typer les query parameters

Les query parameters sont toujours des strings dans l'URL. Il faut les convertir :

typescriptconst PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(["name", "createdAt", "updatedAt"]).default("createdAt"),
  order: z.enum(["asc", "desc"]).default("desc"),
})

type Pagination = z.infer<typeof PaginationSchema>

// Utilisation dans un handler
app.get("/api/users", async (req, res) => {
  const result = PaginationSchema.safeParse(req.query)
  if (!result.success) {
    return res.status(400).json({ error: result.error.flatten() })
  }

  const { page, limit, sort, order } = result.data
  // page: number, limit: number, sort: "name" | "createdAt" | "updatedAt"
})

z.coerce.number() convertit "1" en 1. Les defaults evitent les paramètres manquants.


Résumé

  • Ne fais jamais as User sur un res.json() — utilise un schema de validation
  • Les discriminated unions (success: true/false) typent proprement les réponses et erreurs
  • Un client API générique avec Zod valide chaque réponse au runtime
  • Dans un monorepo, les schemas partages entre serveur et client sont la source de vérité
  • Pour les APIs externes, openapi-typescript généré les types depuis le schema OpenAPI

Article précédent : 04 - Organiser ses types dans un projet

Article suivant : 06 - Error handling type

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.