Creating Custom Plugins
Build your own plugins to extend ts-contract with custom functionality.
Plugin Interfaces
HTTP Plugins
HTTP plugins implement the ContractPlugin interface:
interface ContractPlugin<Name extends string = string> {
name: Name;
route: (route: RouteDef) => Record<string, unknown>;
}WebSocket Plugins
WebSocket plugins implement the WebSocketPlugin interface:
interface WebSocketPlugin<Name extends string = string> {
name: Name;
websocket: (ws: WebSocketDef) => Record<string, unknown>;
}Basic Plugin Example
Here's a simple plugin that adds a logRoute() method to each route:
import type { ContractPlugin, RouteDef } from '@ts-contract/core';
// Step 1: Declare types using module augmentation
declare module '@ts-contract/core' {
interface PluginTypeRegistry<R> {
logger: {
logRoute: () => void;
};
}
}
// Step 2: Implement the plugin
export const loggerPlugin: ContractPlugin<'logger'> = {
name: 'logger',
route: (route: RouteDef) => ({
logRoute: () => {
console.log(`${route.method} ${route.path}`);
},
}),
};
// Step 3: Use the plugin
import { initContract } from '@ts-contract/core';
import { contract } from './contract';
const api = initContract(contract)
.use(loggerPlugin)
.build();
api.getUser.logRoute();
// => "GET /users/:id"Step-by-Step Guide
Step 1: Type Registry Declaration
Use TypeScript's declaration merging to register your plugin's return types:
declare module '@ts-contract/core' {
interface PluginTypeRegistry<R> {
// Plugin name as the key
myPlugin: {
// Methods your plugin adds
myMethod: () => string;
anotherMethod: (arg: string) => number;
};
}
}The generic <R> represents the route definition and can be used for type-safe method signatures:
import type { RouteDef, InferPathParams } from '@ts-contract/core';
declare module '@ts-contract/core' {
interface PluginTypeRegistry<R> {
myPlugin: {
// Use R to make methods type-safe based on the route
getParams: R extends RouteDef ? () => InferPathParams<R> : never;
};
}
}Step 2: Implement the Plugin
Create the plugin object:
import type { ContractPlugin, RouteDef } from '@ts-contract/core';
export const myPlugin: ContractPlugin<'myPlugin'> = {
name: 'myPlugin',
route: (route: RouteDef) => ({
myMethod: () => {
// Access route properties
return `${route.method} ${route.path}`;
},
anotherMethod: (arg: string) => {
return arg.length;
},
}),
};Step 3: Use the Plugin
Add it to your contract:
const api = initContract(contract)
.use(myPlugin)
.build();
// Methods are now available
api.getUser.myMethod();
api.getUser.anotherMethod('test');Real-World Examples
Example 1: OpenAPI Metadata Plugin
declare module '@ts-contract/core' {
interface PluginTypeRegistry<R> {
openapi: {
getOperationId: () => string;
};
}
}
export const openapiPlugin: ContractPlugin<'openapi'> = {
name: 'openapi',
route: (route: RouteDef) => ({
getOperationId: () => {
return `${route.method.toLowerCase()}_${route.path.replace(/[/:]/g, '_')}`;
},
}),
};Example 2: Mock Data Generator Plugin
Generate cache keys for React Query or SWR:
import type { ContractPlugin, RouteDef, InferPathParams, InferQuery } from '@ts-contract/core';
type CacheKeyArgs<R extends RouteDef> = {
params?: InferPathParams<R>;
query?: InferQuery<R>;
};
declare module '@ts-contract/core' {
interface PluginTypeRegistry<R> {
cache: {
getCacheKey: R extends RouteDef
? (args?: CacheKeyArgs<R>) => string[]
: never;
};
}
}
export const cachePlugin: ContractPlugin<'cache'> = {
name: 'cache',
route: (route: RouteDef) => ({
getCacheKey: (args: any = {}) => {
const key = [route.method, route.path];
if (args.params) {
key.push(JSON.stringify(args.params));
}
if (args.query) {
key.push(JSON.stringify(args.query));
}
return key;
},
}),
};
// Usage with React Query
import { useQuery } from '@tanstack/react-query';
const api = initContract(contract)
.use(cachePlugin)
.build();
function useUser(id: string) {
return useQuery({
queryKey: api.getUser.getCacheKey({ params: { id } }),
queryFn: async () => {
const response = await fetch(`/users/${id}`);
return response.json();
},
});
}Advanced Patterns
Accessing Route Schemas
Access the route's schemas within your plugin:
export const schemaPlugin: ContractPlugin<'schema'> = {
name: 'schema',
route: (route: RouteDef) => ({
getSchemas: () => ({
pathParams: route.pathParams,
query: route.query,
body: route.body,
headers: route.headers,
responses: route.responses,
}),
}),
};Type-Safe Plugin Arguments
Make plugin methods type-safe based on the route:
import type { InferPathParams } from '@ts-contract/core';
declare module '@ts-contract/core' {
interface PluginTypeRegistry<R> {
typedPlugin: {
doSomething: R extends RouteDef
? (params: InferPathParams<R>) => string
: never;
};
}
}
export const typedPlugin: ContractPlugin<'typedPlugin'> = {
name: 'typedPlugin',
route: (route: RouteDef) => ({
doSomething: (params: any) => {
// params is type-safe based on the route's pathParams
return JSON.stringify(params);
},
}),
};Stateful Plugins
Plugins can maintain state:
export const statsPlugin: ContractPlugin<'stats'> = {
name: 'stats',
route: (route: RouteDef) => {
let callCount = 0;
return {
incrementCalls: () => {
callCount++;
},
getCallCount: () => {
return callCount;
},
};
},
};
// Each route gets its own state
api.getUser.incrementCalls();
api.getUser.incrementCalls();
console.log(api.getUser.getCallCount()); // => 2
console.log(api.createUser.getCallCount()); // => 0Composing with Other Plugins
Plugins can work together:
// Use both pathPlugin and your custom plugin
const api = initContract(contract)
.use(pathPlugin)
.use(myPlugin)
.build();
// Both sets of methods are available
api.getUser.buildPath({ id: '123' });
api.getUser.myMethod();Testing Plugins
Unit Testing
Test your plugin in isolation:
import { describe, it, expect } from 'vitest';
import { createContract, initContract } from '@ts-contract/core';
import { z } from 'zod';
import { loggerPlugin } from './logger-plugin';
describe('loggerPlugin', () => {
it('logs route information', () => {
const contract = createContract({
getUser: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
200: z.object({ id: z.string() }),
},
},
});
const api = initContract(contract)
.use(loggerPlugin)
.build();
const consoleSpy = vi.spyOn(console, 'log');
api.getUser.logRoute();
expect(consoleSpy).toHaveBeenCalledWith('GET /users/:id');
});
});Integration Testing
Test plugins with real contracts:
import { describe, it, expect } from 'vitest';
describe('Plugin Integration', () => {
it('works with multiple plugins', () => {
const api = initContract(contract)
.use(pathPlugin)
.use(validatePlugin)
.use(myPlugin)
.build();
// Test that all plugin methods are available
expect(api.getUser.buildPath).toBeDefined();
expect(api.getUser.validateResponse).toBeDefined();
expect(api.getUser.myMethod).toBeDefined();
});
});Publishing Plugins
Package Structure
my-plugin/
├── src/
│ └── index.ts
├── package.json
├── tsconfig.json
└── README.mdpackage.json
{
"name": "@myorg/ts-contract-plugin-myplugin",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"peerDependencies": {
"@ts-contract/core": "^1.0.0"
},
"devDependencies": {
"@ts-contract/core": "^1.0.0",
"typescript": "^5.0.0"
}
}README Template
# @myorg/ts-contract-plugin-myplugin
Description of what your plugin does.
## Installation
\`\`\`bash
pnpm add @myorg/ts-contract-plugin-myplugin
\`\`\`
## Usage
\`\`\`ts
import { initContract } from '@ts-contract/core';
import { myPlugin } from '@myorg/ts-contract-plugin-myplugin';
const api = initContract(contract)
.use(myPlugin)
.build();
\`\`\`
## API
### myMethod()
Description of the method.Best Practices
1. Single Responsibility
Each plugin should do one thing well:
// ✓ Good - focused plugin
export const pathPlugin: ContractPlugin<'path'> = {
name: 'path',
route: (route) => ({
buildPath: (...args) => { /* ... */ },
}),
};
// ✗ Avoid - too many responsibilities
export const everythingPlugin: ContractPlugin<'everything'> = {
name: 'everything',
route: (route) => ({
buildPath: (...args) => { /* ... */ },
validate: (...args) => { /* ... */ },
mock: (...args) => { /* ... */ },
log: (...args) => { /* ... */ },
}),
};2. Descriptive Names
Use clear, descriptive names:
// ✓ Good
export const validationPlugin: ContractPlugin<'validation'> = { /* ... */ };
export const openapiPlugin: ContractPlugin<'openapi'> = { /* ... */ };
// ✗ Avoid
export const plugin1: ContractPlugin<'p1'> = { /* ... */ };
export const myPlugin: ContractPlugin<'mp'> = { /* ... */ };3. Type Safety
Leverage TypeScript for type-safe plugin methods:
declare module '@ts-contract/core' {
interface PluginTypeRegistry<R> {
myPlugin: {
// Use route types for type-safe methods
myMethod: R extends RouteDef
? (params: InferPathParams<R>) => string
: never;
};
}
}4. Error Handling
Provide clear error messages:
export const myPlugin: ContractPlugin<'myPlugin'> = {
name: 'myPlugin',
route: (route) => ({
myMethod: (arg: string) => {
if (!arg) {
throw new Error(
`myMethod requires an argument for route ${route.path}`
);
}
return arg;
},
}),
};5. Documentation
Document your plugin's purpose and usage:
/**
* Plugin that adds logging capabilities to routes.
*
* @example
* ```ts
* const api = initContract(contract)
* .use(loggerPlugin)
* .build();
*
* api.getUser.logRoute();
* ```
*/
export const loggerPlugin: ContractPlugin<'logger'> = { /* ... */ };Creating WebSocket Plugins
WebSocket plugins use a separate interface and registry:
import type { WebSocketPlugin, WebSocketDef } from '@ts-contract/core';
// Step 1: Declare types using module augmentation
declare module '@ts-contract/core' {
interface WebSocketPluginTypeRegistry<W> {
wsLogger: {
logConnection: () => void;
};
}
}
// Step 2: Implement the plugin
export const wsLoggerPlugin: WebSocketPlugin<'wsLogger'> = {
name: 'wsLogger',
websocket: (ws: WebSocketDef) => ({
logConnection: () => {
console.log(`WebSocket: ${ws.path}`);
console.log(`Client messages: ${Object.keys(ws.clientMessages).join(', ')}`);
console.log(`Server messages: ${Object.keys(ws.serverMessages).join(', ')}`);
},
}),
};
// Step 3: Use the plugin
const api = initContract(contract)
.useWebSocket(wsLoggerPlugin)
.build();
api.chat.logConnection();
// => "WebSocket: /ws/chat/:roomId"
// => "Client messages: new_msg, typing"
// => "Server messages: new_msg, user_joined"Type-Safe WebSocket Plugin
Leverage WebSocket inference types:
import type {
WebSocketPlugin,
WebSocketDef,
InferClientMessages,
} from '@ts-contract/core';
declare module '@ts-contract/core' {
interface WebSocketPluginTypeRegistry<W> {
wsHelper: {
getClientEventNames: W extends WebSocketDef
? () => Array<keyof InferClientMessages<W>>
: never;
};
}
}
export const wsHelperPlugin: WebSocketPlugin<'wsHelper'> = {
name: 'wsHelper',
websocket: (ws: WebSocketDef) => ({
getClientEventNames: () => Object.keys(ws.clientMessages),
}),
};Next Steps
- Review Path Plugin and WebSocket Plugins source code
- See Validate Plugin for validation patterns
- Explore Plugin System for how plugins work