🚧

Alpha Release - This project is in early development and APIs may change

ts-contractts-contract
Plugins

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()); // => 0

Composing 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.md

package.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