ts-procedures
v5.5.0
Published
A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.
Maintainers
Readme
ts-procedures
A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation and procedure documentation/configuration.
Installation
npm install ts-proceduresQuick Start
import { Procedures } from 'ts-procedures'
import { Type } from 'typebox'
// Create a procedures factory
const { Create } = Procedures()
// Define a procedure with schema validation
const { GetUser, procedure, info } = Create(
'GetUser',
{
description: 'Fetches a user by ID',
schema: {
params: Type.Object({ userId: Type.String() }),
returnType: Type.Object({ id: Type.String(), name: Type.String() }),
},
},
async (ctx, params /* typed as { userId: string } */) => {
// returnType is inferred as { id: string; name: string }
return { id: params.userId, name: 'John Doe' }
},
)
// Call the procedure directly
const user = await GetUser({}, { userId: '123' })
// Or use the generic reference
const user2 = await procedure({}, { userId: '456' })Core Concepts
Procedures Factory
The Procedures() function creates a factory for defining procedures. It accepts two generic type parameters:
Procedures<TContext, TExtendedConfig>(builder?: {
onCreate?: (procedure: TProcedureRegistration<TContext, TExtendedConfig>) => void
})| Parameter | Description |
|-----------|----------------------------------------------------------------------------|
| TContext | The base context type passed to all handlers as the first parameter |
| TExtendedConfig | Additional configuration properties for all procedures config properties |
| builder.onCreate | Optional callback invoked when each procedure is registered (runtime) |
Create Function
The Create function defines individual procedures:
Create(name, config, handler)Returns:
{ [name]: handler }- Named export for the handlerprocedure- Generic reference to the handlerinfo- Procedure meta (name, description, schema,TExtendedConfigproperties, etc.)
Structured Input with schema.input
For HTTP APIs and other multi-channel transports, schema.input provides per-channel type safety. Each key is an independently validated input channel:
const { Create } = Procedures<AppContext, APIConfig>()
const { UpdateUser } = Create(
'UpdateUser',
{
path: '/users/:id',
method: 'put',
schema: {
input: {
pathParams: Type.Object({ id: Type.String() }),
query: Type.Object({ notify: Type.Optional(Type.Boolean()) }),
body: Type.Object({ name: Type.String(), email: Type.String() }),
},
returnType: Type.Object({ ok: Type.Boolean() }),
},
},
async (ctx, { pathParams, query, body }) => {
// Each channel is independently typed and validated
await updateUser(pathParams.id, body)
if (query.notify) await sendNotification(pathParams.id)
return { ok: true }
}
)Rules:
schema.inputandschema.paramsare mutually exclusive — defining both throwsProcedureRegistrationError- Each channel is validated independently with per-channel error messages
- Works with both
CreateandCreateStream
CreateStream Function
The CreateStream function defines streaming procedures that yield values over time using async generators:
CreateStream(name, config, handler)Config Options:
schema.params- Input parameter schema (validated at runtime)schema.yieldType- Schema for each yielded value (validated ifvalidateYields: true)schema.returnType- Schema for final return value (documentation only)validateYields- Enable runtime validation of yielded values (default:false)
Handler Signature:
async function* (ctx, params) => AsyncGenerator<TYield, TReturn | void>Context Extensions (all handlers):
ctx.error(message, meta?)- Create a ProcedureErrorctx.signal?- AbortSignal for cancellation support (optional forCreate, always present forCreateStream)
When using the built-in HTTP implementations (Hono, Express), ctx.signal is automatically injected from the HTTP request, so handlers can detect client disconnection. For direct usage without a server, signal is undefined unless you pass one in context.
Returns:
{ [name]: handler }- Named generator exportprocedure- Generic reference to the generatorinfo- Procedure meta withisStream: true
Using Generics
Base Context
Define a shared context type for all procedures in your application:
interface AppContext {
authToken: string
requestId: string
logger: Logger
}
const { Create } = Procedures<AppContext>()
const { SecureEndpoint } = Create(
'SecureEndpoint',
{},
async (ctx, params) => {
// ctx.authToken is typed as string
// ctx.requestId is typed as string
// ctx.logger is typed as Logger
return { token: ctx.authToken }
},
)
// When calling, you must provide the context
await SecureEndpoint({ authToken: 'abc', requestId: '123', logger: myLogger }, {})Extended Configuration
Add custom properties to all procedure configs:
interface ExtendedConfig {
permissions: string[]
rateLimit?: number
cacheTTL?: number
}
const { Create } = Procedures<AppContext, ExtendedConfig>()
const { AdminOnly } = Create(
'AdminOnly',
{
permissions: ['admin'], // Required by ExtendedConfig
rateLimit: 100, // Optional
description: 'Admin-only endpoint',
},
async (ctx, params) => {
return { admin: true }
},
)
// Access extended config via info
console.log(AdminOnly.info.permissions) // ['admin']Combined Example
interface CustomContext {
authToken: string
tenantId: string
}
interface ExtendedConfig {
requiresAuth: boolean
auditLog?: boolean
}
const { Create, getProcedures } = Procedures<CustomContext, ExtendedConfig>({
onCreate: (procedure) => {
// Register with your framework
console.log(`Registered: ${procedure.name}`)
console.log(`Requires Auth: ${procedure.config.requiresAuth}`)
},
})
const { CreateUser } = Create(
'CreateUser',
{
requiresAuth: true,
auditLog: true,
description: 'Creates a new user',
schema: {
params: Type.Object({
email: Type.String(),
name: Type.String(),
}),
returnType: Type.Object({ id: Type.String() }),
},
},
async (ctx, params) => {
// Both context and params are fully typed
return { id: 'user-123' }
},
)Schema Validation
Suretype
import { v } from 'suretype'
Create(
'CreatePost',
{
schema: {
params: Type.Object({
title: Type.String(),
content: Type.String(),
tags: Type.array(Type.String()),
}),
returnType: Type.Object({
id: Type.String(),
createdAt: Type.String(),
}),
},
},
async (ctx, params) => {
// params typed as { title: string, content: string, tags?: string[] }
return { id: '1', createdAt: new Date().toISOString() }
},
)TypeBox
import { Type } from 'typebox'
Create(
'CreatePost',
{
schema: {
params: Type.Object({
title: Type.String(),
content: Type.String(),
tags: Type.Optional(Type.Array(Type.String())),
}),
returnType: Type.Object({
id: Type.String(),
createdAt: Type.String(),
}),
},
},
async (ctx, params) => {
// params typed as { title: string, content: string, tags?: string[] }
return { id: '1', createdAt: new Date().toISOString() }
},
)Validation Behavior
AJV is configured with:
allErrors: true- Report all validation errorscoerceTypes: true- Automatically coerce types when possibleremoveAdditional: true- Strip properties not in schema
Note: schema.params is validated at runtime. schema.returnType is for documentation/introspection only.
Skipping Validation with isPrevalidated
When building framework integrations that validate params before calling procedure handlers, you can pass isPrevalidated: true in the context to skip duplicate validation:
// Framework integration example
app.post('/rpc/:name', async (req, res) => {
const procedure = getProcedure(req.params.name)
// Validate params at the framework level
const { errors } = procedure.config.validation?.params?.(req.body)
if (errors) {
return res.status(400).json({ errors })
}
// Call handler with isPrevalidated to skip redundant validation
const result = await procedure.handler(
{ ...context, isPrevalidated: true },
req.body
)
res.json(result)
})This is useful for:
- Framework integrations (like
HonoStreamAppBuilder) that validate before starting streams - Custom middleware that performs early validation for better error responses
- Performance optimization when validation has already occurred
Streaming Procedures
Streaming procedures use async generators to yield values over time, enabling SSE (Server-Sent Events), HTTP streaming, and real-time data feeds.
Basic Streaming
import { Procedures } from 'ts-procedures'
import { v } from 'suretype'
const { CreateStream } = Procedures<{ userId: string }>()
const { StreamUpdates } = CreateStream(
'StreamUpdates',
{
description: 'Stream real-time updates',
schema: {
params: v.object({ topic: v.string().required() }),
yieldType: v.object({
id: v.string().required(),
message: v.string().required(),
timestamp: v.number().required(),
}),
},
},
async function* (ctx, params) {
// Types are inferred from schema:
// - params.topic: string
// - yield value must match { id, message, timestamp }
// - ctx.signal: AbortSignal for cancellation
let counter = 0
while (!ctx.signal.aborted) {
yield {
id: `${counter++}`,
message: `Update for ${params.topic}`,
timestamp: Date.now(),
}
await new Promise(r => setTimeout(r, 1000))
}
},
)
// Consume the stream
for await (const update of StreamUpdates({ userId: 'user-123' }, { topic: 'news' })) {
console.log(update.message)
}Yield Validation
By default, yielded values are not validated for performance. Enable validation with validateYields: true:
const { ValidatedStream } = CreateStream(
'ValidatedStream',
{
schema: {
yieldType: v.object({ count: v.number().required() }),
},
validateYields: true, // Enable runtime validation of each yield
},
async function* () {
yield { count: 1 } // Valid
yield { count: 2 } // Valid
// yield { count: 'invalid' } // Would throw ProcedureYieldValidationError
},
)Abort Signal Integration
Streaming Procedures
The ctx.signal allows stream handlers to detect when consumers stop iterating. After completion, signal.reason indicates why the stream ended:
const { CancellableStream } = CreateStream(
'CancellableStream',
{},
async function* (ctx) {
try {
while (!ctx.signal.aborted) {
yield await fetchNextItem()
}
} finally {
// Distinguish normal completion from client disconnect
if (ctx.signal.reason === 'stream-completed') {
// Stream finished normally
} else {
// Client disconnected or external abort
}
await cleanup()
}
},
)
// Consumer can break early - signal.aborted becomes true
for await (const item of CancellableStream({}, {})) {
if (shouldStop) break // Triggers abort
}Regular Procedures
For regular procedures, ctx.signal is available when the server implementation provides it. The built-in HTTP integrations (Hono RPC, Express RPC) inject the request's abort signal automatically:
const { Create } = Procedures<{ signal: AbortSignal }>()
const { LongQuery } = Create(
'LongQuery',
{},
async (ctx, params) => {
// Pass signal to downstream operations
const result = await fetch('https://api.example.com/data', {
signal: ctx.signal,
})
return result.json()
},
)When using the Hono or Express implementations, ctx.signal aborts when the client disconnects, automatically cancelling in-flight fetch() calls, database queries, or any other signal-aware operation.
SSE Integration Example
import express from 'express'
import { Procedures } from 'ts-procedures'
const app = express()
const { CreateStream, getProcedures } = Procedures<{ req: express.Request }>({
onCreate: (proc) => {
if (proc.isStream) {
// Register streaming procedures as SSE endpoints
app.get(`/stream/${proc.name}`, async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
const generator = proc.handler({ req }, req.query)
req.on('close', async () => {
// Client disconnected - stop the generator
await generator.return(undefined)
})
try {
for await (const data of generator) {
res.write(`data: ${JSON.stringify(data)}\n\n`)
}
} finally {
res.end()
}
})
}
},
})
// Define a streaming procedure
CreateStream(
'LiveFeed',
{
schema: {
params: v.object({ channel: v.string() }),
yieldType: v.object({ event: v.string(), data: v.any() }),
},
},
async function* (ctx, params) {
while (!ctx.signal.aborted) {
const event = await pollForEvent(params.channel)
yield event
}
},
)
app.listen(3000)
// SSE endpoint: GET /stream/LiveFeed?channel=updatesStream Errors
Streaming procedures support the same error handling as regular procedures:
const { StreamWithErrors } = CreateStream(
'StreamWithErrors',
{},
async function* (ctx) {
yield { status: 'starting' }
const data = await fetchData()
if (!data) {
throw ctx.error('No data available', { code: 'NO_DATA' })
}
yield { status: 'complete', data }
},
)
try {
for await (const item of StreamWithErrors({}, {})) {
console.log(item)
}
} catch (e) {
if (e instanceof ProcedureError) {
console.log(e.message) // 'No data available'
console.log(e.meta) // { code: 'NO_DATA' }
}
}Error Handling
Using ctx.error()
The error() function is injected into both hooks and handlers:
Create(
'GetResource',
{},
async (ctx, params) => {
const resource = await db.find(params.id)
if (!resource) {
throw ctx.error(404, 'Resource not found', { id: params.id })
}
return resource
},
)Error Handling
| Error Class | Trigger |
|-------------|---------|
| ProcedureError | ctx.error() in handlers |
| ProcedureValidationError | Schema validation failure (params) |
| ProcedureYieldValidationError | Yield validation failure (streaming with validateYields: true) |
| ProcedureRegistrationError | Invalid schema at registration |
Error Properties
try {
await MyProcedure(ctx, params)
} catch (e) {
if (e instanceof ProcedureError) {
console.log(e.procedureName) // 'MyProcedure'
console.log(e.message) // 'Resource not found'
console.log(e.meta) // { id: '123' }
}
}Framework Integration
onCreate Callback
Register procedures with your framework (Express, Fastify, etc.):
import express from 'express'
const app = express()
const routes: Map<string, Function> = new Map()
const { Create } = Procedures<{ req: Request; res: Response }>({
onCreate: ({ name, handler, config }) => {
// Register as Express route
app.post(`/rpc/${name}`, async (req, res) => {
try {
const result = await handler({ req, res }, req.body)
res.json(result)
} catch (e) {
if (e instanceof ProcedureError) {
res.status(500).json({ error: e.message })
} else {
res.status(500).json({ error: 'Internal error' })
}
}
})
},
})
// Procedures are automatically registered as /rpc/GetUser, /rpc/CreateUser, etc.Express RPC Integration
ts-procedures includes an RPC-style HTTP integration for Express that creates POST routes at /rpc/{name}/{version} paths with automatic JSON schema documentation.
import { ExpressRPCAppBuilder, RPCConfig } from 'ts-procedures/express-rpc'
// Create procedure factory with RPC config
const RPC = Procedures<AppContext, RPCConfig>()
// Define procedures with name and version
RPC.Create(
'GetUser',
{
name: ['users', 'get'],
version: 1,
schema: {
params: Type.Object({ id: Type.String() }),
returnType: Type.Object({ id: Type.String(), name: Type.String() }),
},
},
async (ctx, params) => {
return { id: params.id, name: 'John Doe' }
}
)
// Build Express app with registered procedures
const app = new ExpressRPCAppBuilder()
.register(RPC, (req) => ({ userId: req.headers['x-user-id'] as string }))
.build()
app.listen(3000)
// Route created: POST /rpc/users/get/1See Express RPC Integration Guide for complete setup instructions including lifecycle hooks, error handling, and route documentation.
Hono API Integration
ts-procedures includes a REST-style HTTP integration for Hono that routes by HTTP method with per-channel input validation via schema.input.
import { Procedures } from 'ts-procedures'
import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
import type { APIConfig } from 'ts-procedures/http'
import { Type } from 'typebox'
const API = Procedures<{ userId: string }, APIConfig>()
API.Create('GetUser', {
path: '/users/:id',
method: 'get',
schema: {
input: {
pathParams: Type.Object({ id: Type.String() }),
},
returnType: Type.Object({ id: Type.String(), name: Type.String() }),
},
}, async (ctx, { pathParams }) => {
return await fetchUser(pathParams.id)
})
API.Create('CreateUser', {
path: '/users',
method: 'post',
schema: {
input: {
body: Type.Object({ name: Type.String(), email: Type.String() }),
},
},
}, async (ctx, { body }) => {
return await createUser(body)
})
const app = await new HonoAPIAppBuilder({ pathPrefix: '/api' })
.register(API, (c) => ({ userId: c.req.header('x-user-id') || 'anonymous' }))
.build()
// Routes:
// GET /api/users/:id → 200
// POST /api/users → 201See Hono API Integration Guide for complete setup.
Introspection with getProcedures()
Access all registered procedures for documentation or routing:
const { Create, getProcedures } = Procedures()
Create('GetUser', { schema: { params: Type.Object({ id: Type.String() }) } }, async () => {})
Create('ListUsers', { schema: { params: Type.Object({}) } }, async () => {})
// Get all registered procedures
const procedures = getProcedures()
// Generate OpenAPI spec
for (const config of procedures) {
console.log(`${config.name}:`, config.schema)
}DocRegistry — Composing Docs from Multiple Builders
Use DocRegistry to compose route documentation from any combination of HTTP builders into a typed envelope:
import { DocRegistry } from 'ts-procedures/http-docs'
const docs = new DocRegistry({
basePath: '/api',
headers: [{ name: 'Authorization', description: 'Bearer token', required: false }],
errors: DocRegistry.defaultErrors(),
})
.from(rpcBuilder)
.from(apiBuilder)
.from(streamBuilder)
app.get('/docs', (c) => c.json(docs.toJSON()))from() stores a reference — routes are read lazily at toJSON() time, so builders can be registered before or after .build(). Supports optional filter and transform options for customizing output.
Testing
Procedures return handlers that can be called directly in tests:
import { describe, test, expect } from 'vitest'
import { Procedures } from 'ts-procedures'
import { Type } from 'typebox'
interface MyCustomContext {
userId?: string
userName?: string
}
const { Create } = Procedures<MyCustomContext>()
const { GetUser, info } = Create(
'GetUser',
{
schema: {
params: Type.Object({ hideName: Type.Optional(Type.Boolean()) }),
returnType: Type.Object({ id: Type.String(), name: Type.String() }),
},
},
async (ctx, params) => {
if (!params.userName || !ctx.userId) {
throw ctx.error('User is not authenticated')
}
return {
id: params.userId,
name: params?.hideName ? '*******' : params.userName
}
},
)
describe('GetUser', () => {
test('returns user', async () => {
const result = await GetUser({userId:'123',userName:'Ray'}, { hideName: false })
expect(result).toEqual({ id: '123', name: 'Ray' })
})
test('hides user name', async () => {
const result = await GetUser({userId:'123',userName:'Ray'}, { hideName: true })
expect(result).toEqual({ id: '123', name: '*******' })
})
test('validates params', async () => {
await expect(GetUser({}, {})).rejects.toThrow(ProcedureValidationError)
})
test('has correct schema', () => {
expect(info.schema.params).toEqual({
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
})
})
})API Reference
Procedures(builder?)
Creates a procedure factory.
Parameters:
builder.onCreate- Callback invoked when each procedure is registered
Returns:
Create- Function to define proceduresgetProcedures()- ReturnsArrayof all registered procedures
Create(name, config, handler)
Defines a procedure.
Parameters:
name- Unique procedure name (becomes named export)config.description- Optional descriptionconfig.schema.params- Suretype or TypeBox schema for params (validated at runtime)config.schema.returnType- Suretype or TypeBox schema for return returnType (documentation only)- Additional properties from
TExtendedConfig handler- Async function(ctx, params) => Promise<returnType>
Returns:
{ [name]: handler }- Named handler exportprocedure- Generic handler referenceinfo- Procedure metareturnType
Type Exports
import {
// Core
Procedures,
// Errors
ProcedureError,
ProcedureValidationError,
ProcedureRegistrationError,
ProcedureYieldValidationError, // For streaming yield validation
// Types
TLocalContext,
TStreamContext, // Streaming context (AbortSignal always present)
TProcedureRegistration,
TStreamProcedureRegistration, // Streaming procedure registration
TNoContextProvided,
// Schema utilities
extractJsonSchema,
schemaParser,
isTypeboxSchema,
isSuretypeSchema,
// Schema types
TJSONSchema,
TSchemaLib,
TSchemaLibGenerator, // AsyncGenerator type utility
TSchemaParsed,
TSchemaValidationError,
Prettify,
} from 'ts-procedures'
// HTTP types
import type { RPCConfig, RPCHttpRouteDoc, StreamHttpRouteDoc, StreamMode, APIConfig, APIHttpRouteDoc, APIInput, HttpMethod } from 'ts-procedures/http'
// Hono API (REST-style)
import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
import type { APIConfig, APIHttpRouteDoc, APIInput, HttpMethod, QueryParser } from 'ts-procedures/hono-api'AI Agent Setup
ts-procedures ships with built-in AI assistant configuration for Claude Code, Cursor, and GitHub Copilot. This gives AI tools framework-aware context when writing ts-procedures code in your project.
Quick Setup
npx ts-procedures-setupThis installs rules for all supported AI tools. You can also target specific tools:
npx ts-procedures-setup claude # Claude Code only
npx ts-procedures-setup cursor # Cursor only
npx ts-procedures-setup copilot # GitHub Copilot onlyWhat Gets Installed
| Tool | Files | Auto-updates? |
|------|-------|---------------|
| Claude Code | .claude/rules/ts-procedures.md, .claude/commands/ts-procedures-scaffold.md, .claude/commands/ts-procedures-review.md, .claude/agents/ts-procedures-architect.md | Yes |
| Cursor | .cursorrules (marker-based section) | Yes |
| GitHub Copilot | .github/copilot-instructions.md (marker-based section) | Yes |
Auto-Updates
After initial setup, rules are automatically refreshed on every npm install or npm update. When ts-procedures publishes a new version, your AI tools get the latest framework guidance without any manual steps.
Claude Code Features
Once installed, Claude Code gets:
- Framework reference — auto-loaded rules with core API, schema system, error handling, and decision framework
- Scaffold command —
/project:ts-procedures-scaffold <type> <Name>generates procedures, streams, and HTTP setups with correct patterns - Review command —
/project:ts-procedures-review <path>checks code against a 60+ item checklist - Architecture agent —
ts-procedures-architecthelps plan procedure structure, schema design, and HTTP implementation choices
CLI Options
npx ts-procedures-setup --force # Overwrite without prompting
npx ts-procedures-setup --dry-run # Preview what would be created/updated
npx ts-procedures-setup --check # Exit with code 1 if files are outdated (for CI)Gitignore
The .claude/ files are auto-generated and regenerated on npm install. You can add them to .gitignore:
# Auto-generated AI agent rules (regenerated on npm install)
.claude/rules/ts-procedures.md
.claude/commands/ts-procedures-*.md
.claude/agents/ts-procedures-*.mdCursor and Copilot files use marker-based sections that coexist with your own rules, so they should typically be committed.
License
MIT
