Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
claude-dev-suite avatar

Type Safe Api

  • 33 installs
  • 27 repo stars
  • Updated July 17, 2026
  • claude-dev-suite/claude-dev-suite

Ensures end-to-end type safety between backend and frontend using Zod-to-OpenAPI, ts-rest, Zodios, and contract testing.

About

Reference for end-to-end type-safe API patterns covering Zod-to-OpenAPI, ts-rest, Zodios, and contract testing. A developer uses it to share types across TypeScript backend and frontend.

  • Zod-to-OpenAPI schema generation
  • ts-rest/Zodios contracts and Pact contract testing

Type Safe Api by the numbers

  • 33 all-time installs (skills.sh)
  • Ranked #3,349 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
  • Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/claude-dev-suite/claude-dev-suite --skill type-safe-api

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs33
repo stars27
Last updatedJuly 17, 2026
Repositoryclaude-dev-suite/claude-dev-suite

What it does

Ensures end-to-end type safety between backend and frontend using Zod-to-OpenAPI, ts-rest, Zodios, and contract testing.

Files

SKILL.mdMarkdownGitHub ↗

Type-Safe API Core Knowledge

Deep Knowledge: Use mcp__documentation__fetch_docs with technology: type-safe-api for comprehensive documentation.

Zod to OpenAPI

Generate OpenAPI specs from Zod schemas for type-first development.

npm install @asteasolutions/zod-to-openapi zod

Define Schemas

import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';

extendZodWithOpenApi(z);

// Schema with OpenAPI metadata
export const UserSchema = z.object({
  id: z.string().openapi({ example: 'user_123' }),
  name: z.string().min(1).openapi({ example: 'John Doe' }),
  email: z.string().email().openapi({ example: 'john@example.com' }),
  role: z.enum(['user', 'admin']).openapi({ example: 'user' }),
  createdAt: z.date().openapi({ example: '2024-01-01T00:00:00Z' }),
}).openapi('User');

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

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

Generate OpenAPI Document

import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';

const registry = new OpenAPIRegistry();

// Register schemas
registry.register('User', UserSchema);
registry.register('CreateUser', CreateUserSchema);

// Register endpoints
registry.registerPath({
  method: 'get',
  path: '/users/{id}',
  summary: 'Get user by ID',
  request: {
    params: z.object({ id: z.string() }),
  },
  responses: {
    200: {
      description: 'User found',
      content: {
        'application/json': { schema: UserSchema },
      },
    },
    404: {
      description: 'User not found',
    },
  },
});

registry.registerPath({
  method: 'post',
  path: '/users',
  summary: 'Create user',
  request: {
    body: {
      content: {
        'application/json': { schema: CreateUserSchema },
      },
    },
  },
  responses: {
    201: {
      description: 'User created',
      content: {
        'application/json': { schema: UserSchema },
      },
    },
  },
});

// Generate OpenAPI document
const generator = new OpenApiGeneratorV3(registry.definitions);
const openApiDocument = generator.generateDocument({
  openapi: '3.0.0',
  info: {
    title: 'User API',
    version: '1.0.0',
  },
  servers: [{ url: 'https://api.example.com' }],
});

---

ts-rest (Contract-First)

Type-safe REST API contracts shared between client and server.

npm install @ts-rest/core
npm install @ts-rest/next        # For Next.js
npm install @ts-rest/react-query # For React Query

Define Contract

// contracts/api.ts
import { initContract } from '@ts-rest/core';
import { z } from 'zod';

const c = initContract();

export const userContract = c.router({
  getUser: {
    method: 'GET',
    path: '/users/:id',
    pathParams: z.object({ id: z.string() }),
    responses: {
      200: z.object({
        id: z.string(),
        name: z.string(),
        email: z.string(),
      }),
      404: z.object({ message: z.string() }),
    },
  },
  createUser: {
    method: 'POST',
    path: '/users',
    body: z.object({
      name: z.string(),
      email: z.string().email(),
    }),
    responses: {
      201: z.object({
        id: z.string(),
        name: z.string(),
        email: z.string(),
      }),
      400: z.object({ message: z.string() }),
    },
  },
  listUsers: {
    method: 'GET',
    path: '/users',
    query: z.object({
      page: z.number().optional(),
      limit: z.number().optional(),
    }),
    responses: {
      200: z.array(z.object({
        id: z.string(),
        name: z.string(),
        email: z.string(),
      })),
    },
  },
});

Server Implementation (Next.js)

// pages/api/[...ts-rest].ts
import { createNextRoute, createNextRouter } from '@ts-rest/next';
import { userContract } from '../../contracts/api';

const router = createNextRouter(userContract, {
  getUser: async ({ params }) => {
    const user = await db.user.findUnique({ where: { id: params.id } });
    if (!user) {
      return { status: 404, body: { message: 'Not found' } };
    }
    return { status: 200, body: user };
  },
  createUser: async ({ body }) => {
    const user = await db.user.create({ data: body });
    return { status: 201, body: user };
  },
  listUsers: async ({ query }) => {
    const users = await db.user.findMany({
      skip: ((query.page ?? 1) - 1) * (query.limit ?? 10),
      take: query.limit ?? 10,
    });
    return { status: 200, body: users };
  },
});

export default createNextRoute(userContract, router);

Client Usage

// lib/api-client.ts
import { initClient } from '@ts-rest/core';
import { userContract } from '../contracts/api';

export const apiClient = initClient(userContract, {
  baseUrl: 'https://api.example.com',
  baseHeaders: {
    Authorization: `Bearer ${getToken()}`,
  },
});

// Usage (fully typed)
const { body: user, status } = await apiClient.getUser({ params: { id: '123' } });
const { body: newUser } = await apiClient.createUser({
  body: { name: 'John', email: 'john@example.com' },
});

React Query Integration

import { initQueryClient } from '@ts-rest/react-query';
import { userContract } from '../contracts/api';

const client = initQueryClient(userContract, {
  baseUrl: 'https://api.example.com',
});

// In component
function UserProfile({ id }: { id: string }) {
  const { data, isLoading } = client.getUser.useQuery(
    ['user', id],
    { params: { id } }
  );

  if (isLoading) return <Spinner />;
  return <div>{data?.body.name}</div>;
}

---

Zodios (Type-Safe REST Client)

npm install @zodios/core zod
npm install @zodios/react # For React hooks

Define API

import { makeApi, Zodios } from '@zodios/core';
import { z } from 'zod';

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

const api = makeApi([
  {
    method: 'get',
    path: '/users/:id',
    alias: 'getUser',
    response: userSchema,
    parameters: [
      { type: 'Path', name: 'id', schema: z.string() },
    ],
  },
  {
    method: 'post',
    path: '/users',
    alias: 'createUser',
    response: userSchema,
    parameters: [
      {
        type: 'Body',
        name: 'body',
        schema: z.object({
          name: z.string(),
          email: z.string().email(),
        }),
      },
    ],
  },
  {
    method: 'get',
    path: '/users',
    alias: 'listUsers',
    response: z.array(userSchema),
    parameters: [
      { type: 'Query', name: 'status', schema: z.string().optional() },
    ],
  },
]);

export const apiClient = new Zodios('https://api.example.com', api);

Client Usage

// Fully typed
const user = await apiClient.getUser({ params: { id: '123' } });
const users = await apiClient.listUsers({ queries: { status: 'active' } });
const newUser = await apiClient.createUser({
  name: 'John',
  email: 'john@example.com',
});

---

Contract Testing

With Pact

npm install -D @pact-foundation/pact
import { Pact } from '@pact-foundation/pact';

const provider = new Pact({
  consumer: 'Frontend',
  provider: 'UserAPI',
});

describe('User API Contract', () => {
  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());
  afterEach(() => provider.verify());

  it('should get user by id', async () => {
    await provider.addInteraction({
      state: 'user with id 123 exists',
      uponReceiving: 'a request to get user 123',
      withRequest: {
        method: 'GET',
        path: '/users/123',
      },
      willRespondWith: {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: '123',
          name: 'John Doe',
          email: 'john@example.com',
        },
      },
    });

    const user = await apiClient.getUser({ params: { id: '123' } });
    expect(user.name).toBe('John Doe');
  });
});

---

Production Readiness

Shared Types Strategy (Monorepo)

packages/
├── api-contracts/      # Shared contracts
│   ├── src/
│   │   ├── schemas.ts  # Zod schemas
│   │   ├── types.ts    # TypeScript types
│   │   └── contract.ts # ts-rest contract
│   └── package.json
├── backend/
│   ├── src/
│   │   └── routes/     # Implements contracts
│   └── package.json
└── frontend/
    ├── src/
    │   └── api/        # Uses contracts
    └── package.json

Breaking Change Detection

// scripts/check-breaking-changes.ts
import { diff } from 'json-diff';
import oldSpec from './openapi-old.json';
import newSpec from './openapi-new.json';

const changes = diff(oldSpec, newSpec);
const breaking = findBreakingChanges(changes);

if (breaking.length > 0) {
  console.error('Breaking changes detected:');
  breaking.forEach(console.error);
  process.exit(1);
}

Checklist

  • [ ] Shared schema package in monorepo
  • [ ] OpenAPI spec generated from schemas
  • [ ] Contract tests between services
  • [ ] Breaking change detection in CI
  • [ ] Type generation automated
  • [ ] Runtime validation on boundaries
  • [ ] Error types included in contracts
  • [ ] Versioning strategy defined

When NOT to Use This Skill

  • tRPC projects (use trpc skill - simpler for full-stack TypeScript)
  • GraphQL APIs (use graphql skill)
  • Simple REST APIs without shared types (use openapi-codegen instead)
  • Non-TypeScript projects
  • Microservices with different languages
  • Public APIs consumed by third parties (OpenAPI spec better)

Anti-Patterns

Anti-PatternWhy It's BadSolution
Sharing database entities as API typesLeaks implementation, tight couplingCreate separate DTOs/schemas
No runtime validationType safety only at compile timeUse Zod for runtime validation
Duplicating schemas between packagesMaintenance burden, drift riskUse shared schema package in monorepo
Not versioning shared typesBreaking changes affect all consumersVersion shared package, use semver
Missing contract testsTypes match but behavior doesn'tImplement Pact or similar contract testing
Mixing type-safety approachesComplexity, inconsistencyChoose one approach (tRPC, ts-rest, or Zod-OpenAPI)
No breaking change detectionSilent failures in productionAdd schema diff checking in CI
Hardcoding types instead of generatingManual sync burdenGenerate from single source of truth

Quick Troubleshooting

IssuePossible CauseSolution
Type mismatches between FE/BEShared types not updatedRegenerate types, check imports
Runtime validation failsRequest doesn't match schemaCheck request payload, update schema
Contract tests failingAPI behavior changedUpdate contract or fix API implementation
Circular dependency errorsFrontend importing backend codeUse separate shared types package
Breaking changes not detectedNo schema diffingAdd schema versioning and diff tool
Schema generation failsInvalid Zod schemaCheck schema syntax, validate with Zod
OpenAPI spec out of syncManual spec editsGenerate spec from Zod schemas
Type inference not workingWrong import or exportVerify type exports from shared package

Reference Documentation

  • Zod to OpenAPI
  • ts-rest
  • Contract Testing

Related skills

Backend & APIsbackendfrontend

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.