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.
npm install ts-proceduresA 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.
``bash`
npm install ts-procedures
`typescript
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' })
`
The Procedures() function creates a factory for defining procedures. It accepts two generic type parameters:
`typescript`
Procedures
onCreate?: (procedure: TProcedureRegistration
})
| 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) |
The Create function defines individual procedures:
`typescript`
Create(name, config, handler)
Returns:
- { [name]: handler } - Named export for the handlerprocedure
- - Generic reference to the handlerinfo
- - Procedure meta (name, description, schema, TExtendedConfig properties, etc.)
The CreateStream function defines streaming procedures that yield values over time using async generators:
`typescript`
CreateStream(name, config, handler)
Config Options:
- schema.params - Input parameter schema (validated at runtime)schema.yieldType
- - Schema for each yielded value (validated if validateYields: true)schema.returnType
- - Schema for final return value (documentation only)validateYields
- - Enable runtime validation of yielded values (default: false)
Handler Signature:
`typescript`
async function* (ctx, params) => AsyncGenerator
Context Extensions:
- ctx.error(message, meta?) - Create a ProcedureErrorctx.signal
- - AbortSignal for cancellation support
Returns:
- { [name]: handler } - Named generator exportprocedure
- - Generic reference to the generatorinfo
- - Procedure meta with isStream: true
Define a shared context type for all procedures in your application:
`typescript
interface AppContext {
authToken: string
requestId: string
logger: Logger
}
const { Create } = Procedures
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 }, {})
`
Add custom properties to all procedure configs:
`typescript
interface ExtendedConfig {
permissions: string[]
rateLimit?: number
cacheTTL?: number
}
const { Create } = Procedures
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']
`
`typescript
interface CustomContext {
authToken: string
tenantId: string
}
interface ExtendedConfig {
requiresAuth: boolean
auditLog?: boolean
}
const { Create, getProcedures } = Procedures
onCreate: (procedure) => {
// Register with your framework
console.log(Registered: ${procedure.name})Requires Auth: ${procedure.config.requiresAuth}
console.log()
},
})
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' }
},
)
`
`typescript
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() }
},
)
`
`typescript
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() }
},
)
`
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.
Streaming procedures use async generators to yield values over time, enabling SSE (Server-Sent Events), HTTP streaming, and real-time data feeds.
`typescript
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++},Update for ${params.topic}
message: ,
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)
}
`
By default, yielded values are not validated for performance. Enable validation with validateYields: true:
`typescript`
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
},
)
The ctx.signal allows handlers to detect when consumers stop iterating:
`typescript
const { CancellableStream } = CreateStream(
'CancellableStream',
{},
async function* (ctx) {
try {
while (!ctx.signal.aborted) {
yield await fetchNextItem()
}
} finally {
// Cleanup when stream ends (consumer stopped or completed)
await cleanup()
}
},
)
// Consumer can break early - signal.aborted becomes true
for await (const item of CancellableStream({}, {})) {
if (shouldStop) break // Triggers abort
}
`
`typescript
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=updates
`
Streaming procedures support the same error handling as regular procedures:
`typescript
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' }
}
}
`
The error() function is injected into both hooks and handlers:
`typescript`
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 Class | Trigger |
|-------------|---------|
| ProcedureError | ctx.error() in handlers |validateYields: true
| ProcedureValidationError | Schema validation failure (params) |
| ProcedureYieldValidationError | Yield validation failure (streaming with ) |
| ProcedureRegistrationError | Invalid schema at registration |
`typescript`
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' }
}
}
Register procedures with your framework (Express, Fastify, etc.):
`typescript
import express from 'express'
const app = express()
const routes: 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.
`
ts-procedures includes an RPC-style HTTP integration for Express that creates POST routes at /rpc/{name}/{version} paths with automatic JSON schema documentation.
`typescript
import { ExpressRPCAppBuilder, RPCConfig } from 'ts-procedures/express-rpc'
// Create procedure factory with RPC config
const RPC = Procedures
// 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/1
`
See Express RPC Integration Guide for complete setup instructions including lifecycle hooks, error handling, and route documentation.
Access all registered procedures for documentation or routing:
`typescript
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)`
}
Procedures return handlers that can be called directly in tests:
`typescript
import { describe, test, expect } from 'vitest'
import { Procedures } from 'ts-procedures'
import { Type } from 'typebox'
interface MyCustomContext {
userId?: string
userName?: string
}
const { Create } = Procedures
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'],
})
})
})
`
Creates a procedure factory.
Parameters:
- builder.onCreate - Callback invoked when each procedure is registered
Returns:
- Create - Function to define proceduresgetProcedures()
- - Returns Array of all registered procedures
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)TExtendedConfig
- Additional properties from handler
- - Async function (ctx, params) => Promise
Returns:
- { [name]: handler } - Named handler exportprocedure
- - Generic handler referenceinfo
- - Procedure metareturnType
`typescript
import {
// Core
Procedures,
// Errors
ProcedureError,
ProcedureValidationError,
ProcedureRegistrationError,
ProcedureYieldValidationError, // For streaming yield validation
// Types
TLocalContext,
TStreamContext, // Streaming context with AbortSignal
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'
``
MIT