Pipes
Transform and validate tool input
Pipes validate and transform input data before it reaches the handler. They ensure that handlers receive correctly typed, sanitized, and validated data, reducing the need for defensive coding within business logic.
Creating Pipes
A pipe is a class with a transform() method. It receives the tool's input and returns the input the handler should see, or throws to reject it.
Basic Pipe
Pipes implement the PipeInterface:
import { Pipe, PipeInterface, ArgumentMetadata } from '@nitrostack/core';
@Pipe()
export class TrimPipe implements PipeInterface {
transform(value: any, metadata?: ArgumentMetadata): any {
return this.trimDeep(value);
}
private trimDeep(value: unknown): unknown {
if (typeof value === 'string') {
return value.trim();
}
if (Array.isArray(value)) {
return value.map(item => this.trimDeep(item));
}
if (value && typeof value === 'object') {
const trimmed: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
trimmed[key] = this.trimDeep(val);
}
return trimmed;
}
return value;
}
}Pipe Interface
transform() gets the value and metadata about it, and returns the transformed value. It can be async.
export interface PipeInterface<T = unknown, R = unknown> {
transform(value: T, metadata: ArgumentMetadata): R | Promise<R>;
}
export interface ArgumentMetadata {
type: 'body' | 'param' | 'query' | 'custom';
metatype?: new (...args: unknown[]) => unknown;
data?: string;
}Parameters:
value: The input data to transform or validatemetadata: Metadata describing the argument type, metatype, and parameter identifier
Return: Transformed or validated value (synchronous or returned as a Promise)
Throws: Error if validation fails
Using Pipes
Attach pipes with @UsePipes. They run in order, each receiving the previous one's output.
On Individual Methods
Put @UsePipes on the handler whose input it should transform.
import { ToolDecorator as Tool, UsePipes, ExecutionContext } from '@nitrostack/core';
import { TrimPipe } from './pipes/trim.pipe.js';
import { ValidationPipe } from './pipes/validation.pipe.js';
export class UserTools {
@Tool({ name: 'create_user' })
@UsePipes(TrimPipe, ValidationPipe)
async createUser(input: CreateUserInput, ctx: ExecutionContext) {
// Input is trimmed and validated
return this.userService.create(input);
}
}Multiple Pipes
Pipes execute in order, each receiving the output of the previous:
@Tool({ name: 'search' })
@UsePipes(
TrimPipe, // 1. Trim whitespace
LowercasePipe, // 2. Convert to lowercase
SanitizePipe, // 3. Sanitize special characters
ValidationPipe // 4. Validate final result
)
async search(input: SearchInput, ctx: ExecutionContext) {
return this.searchService.query(input.query);
}Common Patterns
Most pipes either clean input up or reject it.
Input Validation
Reject input that's missing entirely, before anything else runs.
@Pipe()
export class ValidationPipe implements PipeInterface {
transform(value: any, metadata?: ArgumentMetadata): any {
if (value === null || value === undefined) {
throw new Error('Input cannot be null or undefined');
}
if (typeof value !== 'object') {
throw new Error('Input must be an object');
}
return value;
}
}Type Coercion
Convert a value to the type the handler expects, and fail clearly when that's not possible.
@Pipe()
export class ParseIntPipe implements PipeInterface {
transform(value: any, metadata?: ArgumentMetadata): number {
const parsed = parseInt(String(value), 10);
if (isNaN(parsed)) {
throw new Error(`Cannot parse "${value}" as integer`);
}
return parsed;
}
}
@Pipe()
export class ParseBoolPipe implements PipeInterface {
transform(value: any, metadata?: ArgumentMetadata): boolean {
if (typeof value === 'boolean') return value;
if (value === 'true' || value === '1') return true;
if (value === 'false' || value === '0') return false;
throw new Error(`Cannot parse "${value}" as boolean`);
}
}
@Pipe()
export class ParseDatePipe implements PipeInterface {
transform(value: any, metadata?: ArgumentMetadata): Date {
const date = new Date(value);
if (isNaN(date.getTime())) {
throw new Error(`Cannot parse "${value}" as date`);
}
return date;
}
}ID Format Validation
Check that an ID has the expected format before you look it up.
@Pipe()
export class UuidValidationPipe implements PipeInterface {
private static readonly UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
transform(value: any, metadata?: ArgumentMetadata): any {
if (!value || typeof value !== 'object') {
return value;
}
const result = { ...value };
for (const [key, val] of Object.entries(result)) {
if (key.endsWith('Id') && typeof val === 'string') {
if (!UuidValidationPipe.UUID_REGEX.test(val)) {
throw new Error(`Invalid UUID format for ${key}: "${val}"`);
}
}
}
return result;
}
}
@Pipe()
export class CustomIdValidationPipe implements PipeInterface {
constructor(private readonly pattern: RegExp, private readonly fieldName: string) {}
transform(value: any, metadata?: ArgumentMetadata): any {
if (!value || typeof value !== 'object') {
return value;
}
const fieldValue = value[this.fieldName];
if (fieldValue && !this.pattern.test(fieldValue)) {
throw new Error(
`Invalid format for ${this.fieldName}: "${fieldValue}"`
);
}
return value;
}
}String Transformation
Normalize chosen fields, for example to lowercase, so comparisons work.
@Pipe()
export class LowercasePipe implements PipeInterface {
private readonly fields: string[];
constructor(fields?: string[]) {
this.fields = fields || [];
}
transform(value: any, metadata?: ArgumentMetadata): any {
if (!value || typeof value !== 'object') {
return value;
}
const result = { ...value };
for (const [key, val] of Object.entries(result)) {
if (typeof val === 'string') {
if (this.fields.length === 0 || this.fields.includes(key)) {
result[key] = val.toLowerCase();
}
}
}
return result;
}
}
@Pipe()
export class SlugifyPipe implements PipeInterface {
private readonly field: string;
constructor(field: string = 'slug') {
this.field = field;
}
transform(value: any, metadata?: ArgumentMetadata): any {
if (!value || typeof value !== 'object' || !value[this.field]) {
return value;
}
return {
...value,
[this.field]: String(value[this.field])
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')
};
}
}Default Values
Fill in fields the caller left out.
@Pipe()
export class DefaultValuesPipe implements PipeInterface {
constructor(private readonly defaults: Record<string, unknown>) {}
transform(value: any, metadata?: ArgumentMetadata): any {
if (!value || typeof value !== 'object') {
return { ...this.defaults };
}
const result = { ...value };
for (const [key, defaultValue] of Object.entries(this.defaults)) {
if (result[key] === undefined || result[key] === null) {
result[key] = defaultValue;
}
}
return result;
}
}
// Usage
@Tool({ name: 'list_items' })
@UsePipes(new DefaultValuesPipe({ page: 1, limit: 20, sortBy: 'createdAt' }))
async listItems(input: ListItemsInput, ctx: ExecutionContext) {
// input.page, input.limit, input.sortBy have defaults
}Sanitization
Strip unsafe content from every string in the input, however deeply nested.
@Pipe()
export class SanitizePipe implements PipeInterface {
transform(value: any, metadata?: ArgumentMetadata): any {
return this.sanitizeDeep(value);
}
private sanitizeDeep(value: unknown): unknown {
if (typeof value === 'string') {
return this.sanitizeString(value);
}
if (Array.isArray(value)) {
return value.map(item => this.sanitizeDeep(item));
}
if (value && typeof value === 'object') {
const sanitized: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
sanitized[key] = this.sanitizeDeep(val);
}
return sanitized;
}
return value;
}
private sanitizeString(str: string): string {
return str
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/javascript:/gi, '')
.replace(/on\w+=/gi, '')
.trim();
}
}Validation Strategies
Your tool's inputSchema already validates input. Use a pipe when you need extra rules.
Schema-Based Validation
Validate against a second Zod schema, for example a stricter one shared by several tools.
import { z, ZodSchema } from 'zod';
@Pipe()
export class ZodValidationPipe implements PipeInterface {
constructor(private readonly schema: ZodSchema) {}
transform(value: any, metadata?: ArgumentMetadata): any {
const result = this.schema.safeParse(value);
if (!result.success) {
const errors = result.error.issues.map(issue => ({
path: issue.path.join('.'),
message: issue.message
}));
throw new Error(
`Validation failed: ${errors.map(e => `${e.path}: ${e.message}`).join(', ')}`
);
}
return result.data;
}
}
// Usage
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
age: z.number().int().positive().optional()
});
@Tool({ name: 'create_user' })
@UsePipes(new ZodValidationPipe(CreateUserSchema))
async createUser(input: z.infer<typeof CreateUserSchema>, ctx: ExecutionContext) {
return this.userService.create(input);
}Field-Level Validation
Check individual fields with your own rules and messages.
@Pipe()
export class FieldValidationPipe implements PipeInterface {
private readonly validations: Map<string, (value: unknown) => boolean>;
private readonly messages: Map<string, string>;
constructor(config: Record<string, { validate: (value: unknown) => boolean; message: string }>) {
this.validations = new Map();
this.messages = new Map();
for (const [field, { validate, message }] of Object.entries(config)) {
this.validations.set(field, validate);
this.messages.set(field, message);
}
}
transform(value: any, metadata?: ArgumentMetadata): any {
if (!value || typeof value !== 'object') {
return value;
}
const errors: string[] = [];
for (const [field, validate] of this.validations) {
if (value[field] !== undefined && !validate(value[field])) {
errors.push(this.messages.get(field) || `Invalid value for ${field}`);
}
}
if (errors.length > 0) {
throw new Error(`Validation failed: ${errors.join('; ')}`);
}
return value;
}
}
// Usage
@Tool({ name: 'create_product' })
@UsePipes(new FieldValidationPipe({
price: {
validate: (v) => typeof v === 'number' && v > 0,
message: 'Price must be a positive number'
},
quantity: {
validate: (v) => Number.isInteger(v) && v >= 0,
message: 'Quantity must be a non-negative integer'
}
}))
async createProduct(input: CreateProductInput, ctx: ExecutionContext) {
return this.productService.create(input);
}