Getting Started
Get up and running with ts-contract quickly.
Install
pnpm add @ts-contract/core @ts-contract/pluginsnpm install @ts-contract/core @ts-contract/pluginsyarn add @ts-contract/core @ts-contract/pluginsbun add @ts-contract/core @ts-contract/pluginsDefine your contract
Use your favorite schema library to define your contract.
import { createContract } from '@ts-contract/core';
import { z } from 'zod';
const contract = createContract({
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().email(),
}),
404: z.object({ message: z.string() }),
},
},
});import { createContract } from '@ts-contract/core';
import * as v from 'valibot';
const contract = createContract({
getUser: {
method: 'GET',
path: '/users/:id',
pathParams: v.object({ id: v.string() }),
responses: {
200: v.object({
id: v.string(),
name: v.string(),
email: v.pipe(v.string(), v.email()),
}),
404: v.object({ message: v.string() }),
},
},
});import { createContract } from '@ts-contract/core';
import { type } from 'arktype';
const contract = createContract({
getUser: {
method: 'GET',
path: '/users/:id',
pathParams: type({ id: 'string' }),
responses: {
200: type({
id: 'string',
name: 'string',
email: 'string.email',
}),
404: type({ message: 'string' }),
},
},
});Initialize your contract and add plugins
Once you've defined your contract, use initContract to create a builder and compose plugins with .use(). Call .build() to produce a fully enhanced contract.
import { } from '@ts-contract/core';
import { , } from '@ts-contract/plugins';
import { } from './contract';
const = ()
.()
.()
.();
// Build a type-safe URL
const = ..({ : '123' });
// => "/users/123"
// Validate incoming data against your schema
const = ..(200, { : '1', : 'Alice', : '[email protected]' });Type inference helpers
Use the built-in type helpers to extract types from your contract routes.
// Infer path parameters
type = <typeof .>;
// Infer a specific response body by status code
type = <typeof ., 200>;
// Infer all responses as a discriminated union
type = <typeof .>;
// Infer all arguments (path, query, body, headers) merged
type = <typeof .>;Fulfill your contract on the server
ts-contract doesn't own your server integration — you use the inference types to wire things up yourself.
import { type InferPathParams, type InferResponseBody } from '@ts-contract/core';
import { Hono } from 'hono';
import { contract } from './contract';
type Params = InferPathParams<typeof contract.getUser>;
// => { id: string }
type UserResponse = InferResponseBody<typeof contract.getUser, 200>;
// => { id: string; name: string; email: string }
const app = new Hono();
app.get('/users/:id', (c) => {
const { id } = c.req.param() as Params;
const user: UserResponse = {
id,
name: 'Alice',
email: '[email protected]',
};
return c.json(user);
});import { type InferPathParams, type InferResponseBody } from '@ts-contract/core';
import express from 'express';
import { contract } from './contract';
type Params = InferPathParams<typeof contract.getUser>;
// => { id: string }
type UserResponse = InferResponseBody<typeof contract.getUser, 200>;
// => { id: string; name: string; email: string }
const app = express();
app.get('/users/:id', (req, res) => {
const { id } = req.params as Params;
const user: UserResponse = {
id,
name: 'Alice',
email: '[email protected]',
};
res.json(user);
});import { type InferPathParams, type InferResponseBody } from '@ts-contract/core';
import Fastify from 'fastify';
import { contract } from './contract';
type Params = InferPathParams<typeof contract.getUser>;
// => { id: string }
type UserResponse = InferResponseBody<typeof contract.getUser, 200>;
// => { id: string; name: string; email: string }
const app = Fastify();
app.get<{ Params: Params }>('/users/:id', async (request) => {
const { id } = request.params;
const user: UserResponse = {
id,
name: 'Alice',
email: '[email protected]',
};
return user;
});Use in your client code
Combine your contract with React Query for fully type-safe data fetching.
import { useQuery } from '@tanstack/react-query';
import { type InferPathParams, type InferResponseBody } from '@ts-contract/core';
import { contract } from './contract';
import { api } from './api';
type Params = InferPathParams<typeof contract.getUser>;
// => { id: string }
type User = InferResponseBody<typeof contract.getUser, 200>;
// => { id: string; name: string; email: string }
export function useUser(id: string) {
return useQuery<User>({
queryKey: ['user', id],
queryFn: async () => {
const url = api.getUser.buildPath({ id });
const res = await fetch(url);
const data = await res.json();
// Validate the response against your schema
return api.getUser.validateResponse(200, data);
},
});
}import { useUser } from './use-user';
function UserProfile({ id }: { id: string }) {
const { data: user, isLoading, error } = useUser(id);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}Common Patterns
Creating a Complete CRUD API
Here's a typical pattern for a resource with full CRUD operations:
import { createContract } from '@ts-contract/core';
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
const contract = createContract({
users: {
list: {
method: 'GET',
path: '/users',
query: z.object({
page: z.string().optional(),
limit: z.string().optional(),
}),
responses: {
200: z.object({
data: z.array(UserSchema),
total: z.number(),
}),
},
},
get: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
200: UserSchema,
404: z.object({ message: z.string() }),
},
},
create: {
method: 'POST',
path: '/users',
body: UserSchema.omit({ id: true }),
responses: {
201: UserSchema,
400: z.object({ message: z.string() }),
},
},
update: {
method: 'PUT',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
body: UserSchema.omit({ id: true }),
responses: {
200: UserSchema,
404: z.object({ message: z.string() }),
},
},
delete: {
method: 'DELETE',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
204: z.null(),
404: z.object({ message: z.string() }),
},
},
},
});Sharing Schemas Across Routes
Extract common schemas to avoid duplication:
import { z } from 'zod';
const ErrorSchema = z.object({ message: z.string() });
const PaginationSchema = z.object({
page: z.string().optional(),
limit: z.string().optional(),
});
const contract = createContract({
getUsers: {
method: 'GET',
path: '/users',
query: PaginationSchema,
responses: {
200: z.array(z.object({ id: z.string(), name: z.string() })),
500: ErrorSchema,
},
},
getPosts: {
method: 'GET',
path: '/posts',
query: PaginationSchema,
responses: {
200: z.array(z.object({ id: z.string(), title: z.string() })),
500: ErrorSchema,
},
},
});Organizing Large APIs
Split your contract into multiple files for better organization:
// contracts/users.ts
export const userRoutes = {
getUser: { /* ... */ },
createUser: { /* ... */ },
};
// contracts/posts.ts
export const postRoutes = {
getPost: { /* ... */ },
createPost: { /* ... */ },
};
// contracts/index.ts
import { createContract } from '@ts-contract/core';
import { userRoutes } from './users';
import { postRoutes } from './posts';
export const contract = createContract({
users: userRoutes,
posts: postRoutes,
});Troubleshooting
TypeScript Errors with Type Inference
Problem: TypeScript can't infer types from your contract.
Solution: Make sure you're using typeof when extracting types:
// ✓ Correct
type User = InferResponseBody<typeof contract.getUser, 200>;
// ✗ Wrong
type User = InferResponseBody<contract.getUser, 200>;Schema Validation Errors
Problem: Validation fails with unexpected errors.
Solution: Ensure your schema library is compatible with Standard Schema. ts-contract works with Zod, Valibot, Arktype, and any library implementing @standard-schema/spec.
Plugin Methods Not Available
Problem: buildPath() or validateResponse() methods don't exist.
Solution: Make sure you've initialized the contract with plugins:
// ✓ Correct - plugins added
const api = initContract(contract)
.use(pathPlugin)
.use(validatePlugin)
.build();
// ✗ Wrong - no plugins
const api = contract;Path Parameters Not Matching
Problem: Path building fails with missing parameter errors.
Solution: Ensure parameter names in pathParams match the placeholders in path:
// ✓ Correct - names match
{
path: '/users/:userId/posts/:postId',
pathParams: z.object({
userId: z.string(),
postId: z.string(),
}),
}
// ✗ Wrong - names don't match
{
path: '/users/:userId/posts/:postId',
pathParams: z.object({
id: z.string(),
post: z.string(),
}),
}What's Next?
Now that you've got the basics, dive deeper into ts-contract:
Core Concepts
- Contracts - Learn how to organize and compose contracts
- Routes & Schemas - Deep dive into route definitions
- Type Inference - Master type extraction from contracts
- Plugin System - Understand how plugins extend functionality
Plugins
- Path Plugin - Build type-safe URLs
- Validate Plugin - Runtime schema validation
- Creating Custom Plugins - Build your own plugins
Recipes
- Server Integrations - Integrate with Hono, Express, Fastify
- Client Integrations - Use with React Query, SWR, and more
- Full-Stack Examples - Complete end-to-end examples