Skip to Content
đź‘‹ Hey there! Welcome to NestJS tRPC.
DocumentationDecoratorsProcedures Overview

Procedures Overview

Procedures are the core building blocks of your tRPC API in NestJS. They define the operations your API can perform and come in three main types: queries, mutations, and subscriptions.

Types of Procedures

Queries

Read operations that don’t modify data. Perfect for fetching users, getting lists, or retrieving any information without side effects.

@Query({
    input: z.object({ id: z.string() }),
    output: UserSchema,
})
async getUser(@Input() input: { id: string }) {
    return await this.userService.findById(input.id)
}

→ Learn more about Query procedures

Mutations

Write operations that create, update, or delete data. Use these for user registration, updating profiles, or any data modification.

@Mutation({
    input: z.object({
        name: z.string(),
        email: z.string().email(),
    }),
    output: UserSchema,
})
async createUser(@Input() input: { name: string; email: string }) {
    return await this.userService.create(input)
}

→ Learn more about Mutation procedures

Subscriptions

Real-time operations that stream data over time. Perfect for notifications, live updates, or any real-time features.

@Subscription({
    input: z.object({ userId: z.string() }),
    output: NotificationSchema,
})
async *onNotification(@Input() input: { userId: string }): AsyncIterable<Notification> {
    // Stream notifications in real-time
}

→ Learn more about Subscription procedures

Parameter Decorators

@Input Decorator

Access and validate input parameters in your procedures with full type safety.

@Query({
    input: z.object({
        search: z.string(),
        limit: z.number().default(10),
    }),
})
async searchUsers(@Input() input: { search: string; limit: number }) {
    // input is fully validated and typed
    return await this.userService.search(input)
}

→ Learn more about Input decorator

@Context Decorator

Access request context, authentication data, and custom properties in your procedures.

@Query()
async getCurrentUser(@Context() ctx: RequestContext) {
    if (!ctx.userId) {
        throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' })
    }
    return await this.userService.findById(ctx.userId)
}

→ Learn more about Context decorator

Common Patterns

Schema Validation

All procedures support comprehensive input and output validation using Zod schemas:

const CreateUserSchema = z.object({
    name: z.string().min(1).max(100),
    email: z.string().email(),
    age: z.number().min(18).max(120),
})
 
@Mutation({
    input: CreateUserSchema,
    output: UserSchema,
})
async createUser(@Input() input: z.infer<typeof CreateUserSchema>) {
    // Input is guaranteed to be valid
    return await this.userService.create(input)
}

Error Handling

Handle errors consistently across all procedure types:

import { TRPCError } from '@trpc/server'
 
@Query()
async getUser(@Input() input: { id: string }) {
    const user = await this.userService.findById(input.id)
 
    if (!user) {
        throw new TRPCError({
            code: 'NOT_FOUND',
            message: 'User not found',
        })
    }
 
    return user
}

Using Middleware

Apply middleware for authentication, validation, and other cross-cutting concerns:

@Query()
@Middleware(AuthMiddleware)
async getPrivateData(@Context() ctx: RequestContext) {
    // AuthMiddleware ensures user is authenticated
    return await this.dataService.getPrivate(ctx.userId)
}

→ Learn more about Middleware

Procedure Configuration

All procedure decorators accept these common options:

OptionTypeDescription
inputZodSchemaInput validation schema (optional)
outputZodSchemaOutput validation schema (optional)
metaobjectMetadata for the procedure (optional)

Best Practices

1. Use Descriptive Names

Choose method names that clearly describe what the procedure does:

// Good
async getUserProfile(@Input() input: { userId: string }) { }
async updateUserEmail(@Input() input: { userId: string; email: string }) { }
 
// Avoid
async get(@Input() input: { id: string }) { }
async update(@Input() input: any) { }

2. Validate All Inputs

Always define input schemas, even for simple procedures:

// Good
@Query({
    input: z.object({ id: z.string().uuid() }),
})
async getUser(@Input() input: { id: string }) { }
 
// Avoid
@Query()
async getUser(@Input() input: any) { }

3. Handle Errors Gracefully

Provide meaningful error messages for different scenarios:

@Query()
async getUser(@Input() input: { id: string }) {
    try {
        const user = await this.userService.findById(input.id)
 
        if (!user) {
            throw new TRPCError({
                code: 'NOT_FOUND',
                message: `User with ID ${input.id} not found`,
            })
        }
 
        return user
    } catch (error) {
        if (error instanceof TRPCError) throw error
 
        throw new TRPCError({
            code: 'INTERNAL_SERVER_ERROR',
            message: 'Failed to fetch user',
            cause: error,
        })
    }
}

4. Keep Procedures Focused

Each procedure should have a single, clear responsibility:

// Good - Single responsibility
@Query()
async getUserProfile(@Input() input: { userId: string }) { }
 
@Query()
async getUserPreferences(@Input() input: { userId: string }) { }
 
// Avoid - Multiple responsibilities
@Query()
async getUserData(@Input() input: { userId: string; includePreferences: boolean }) { }

Next Steps

Explore specific procedure types and decorators:

Last updated on: