Core concepts

Decorators as build-time annotations, awilix DI, and the two generated artifacts.

Decorators are build-time annotations

The decorators (@Controller, @Injectable, @Get, @UseGuards, …) are no-ops at runtime. They exist so the class is valid TypeScript and so the codegen's AST has something to read. There is no reflect-metadata, no design:paramtypes, nothing inspected at runtime.

export const Controller =
  (_path?: string): ClassDecorator =>
  () => {}

All the meaning is extracted at build time by reading the syntax tree. The decorators and roost types are auto-imported into server code, so you write @Controller() / @Get() / Ctx without an import line — see Getting started.

Dependency injection (awilix)

roost uses awilix with request scopes and strict: true (which throws on lifetime leakage — a SINGLETON depending on a SCOPED provider — at boot, not in production).

  • SINGLETON providers (plain @Injectable()) live for the process — the shared store, the DB pool.
  • SCOPED providers (@Scoped(), or @Injectable({ scope: 'scoped' }); @Controller and guards/filters/interceptors by default) are built fresh per request and can inject the per-request RequestContext.
  • TRANSIENT providers (@Transient(), or @Injectable({ scope: 'transient' })) get a new instance on every resolution.

@Scoped() / @Transient() are roost shorthands — less boilerplate than the @Injectable({ scope }) long form, which stays for Nest familiarity. Both work.

The dependency graph comes from constructor parameter typesconstructor(private repo: ProjectsRepo) means "inject projectsRepo". roost emits explicit asFunction factories from these, so it never relies on awilix's minification-fragile param-name parsing.

Request input: validation & injection

Validation always comes from a zod schema (there's no reflect-metadata, so a type can't be a validator — a runtime schema is the minimal requirement). There are two ways to take the validated body; they're equivalent in guarantee, differing only in how the handler reads it:

// Injection (Nest-faithful): the validated body is bound as the argument.
@Post()
create(@Body(createProjectSchema) dto: CreateProjectDto) {
  return this.projects.create(dto.name)
}

// Context style: validate ctx.body in place, read it off ctx.
@Post()
@ValidateBody(createProjectSchema)
create(ctx: Ctx<CreateProjectDto>) {
  return this.projects.create(ctx.body.name)
}

The injection decorators bind handler arguments from the request, in declaration order:

DecoratorInjects
@Body(schema)the validated body (bad input → 400 before the handler)
@Body(null)the raw body, no validation — explicit opt-out
@Body() (bare)a build error — pass a schema, or @Body(null) to skip
@Param('id') / @Param() / @Param('id', pipe)a route param; bare @Param() infers the key from the parameter name; an optional pipe transforms/validates it
@Query(schema)the validated whole query (@Query(null) raw, bare @Query() errors)
@Query('page') / @Query('page', pipe)a single query param (raw, or run through a pipe)
@Ctx()the full Ctx (an undecorated ctx: Ctx param does too)

@Body is only the param injector; the method-level validation marker is named @ValidateBody so the name never implies the Nest-style injection that form doesn't do. A bare @Body() is rejected at build time on purpose — injection never silently skips validation, the way a reflect-metadata framework's @Body() might appear to validate when nothing is wired.

Validation runs through zod's parseAsync, so async refinements work — a schema with an async .refine (e.g. a DB-backed uniqueness check) validates before the handler, and a rejection still maps to a 400 like any other validation failure.

@Param() infers its key from the parameter name. get(@Param() id: string) binds ctx.params.id — roost reads the parameter name from the AST at build time (before any minifier renames it), so repeating 'id' is unnecessary. Nest needs @Param('id') because it reads names at runtime, where minification has erased them; roost's build-time pass doesn't have that limitation. Pass an explicit key only when the names differ (@Param('id') userId). Either way, the key is checked against the route path at build time — a @Param with no matching :key is a hard error, so a typo surfaces at codegen, not as a silent undefined in production.

Pipes

A "pipe" in roost is just a zod schema — the thing @Body/@Query/@Param already accept. A zod schema does both NestJS pipe jobs at once: it transforms (coerce, .transform(), .default()) and validates (throwing → 400 on bad input). So instead of Nest's menu of single-purpose pipe classes, you pass one schema:

@Get(':id')
findOne(@Param('id', ParseInt) id: number) {   // "0x1f"/"1.5"/"abc" → 400; "42" → 42
  return this.cats.findOne(id)
}

@Get()
findAll(@Query('page', ParseInt.default(1)) page: number) {   // ?page=2 → 2, absent → 1
  return this.cats.page(page)
}

ParseInt/ParseFloat/ParseBool/ParseUUID/ParseEnum/ParseArray/DefaultValue are auto-imported helpers mirroring Nest's built-in pipes — but they're plain schemas, so they map to zod idioms and chain with any zod method:

NestJS piperoost
ParseIntPipeParseInt (z.string().regex(int).transform(Number))
ParseFloatPipeParseFloat
ParseBoolPipeParseBool (z.stringbool())
ParseUUIDPipeParseUUID (z.uuid())
ParseEnumPipeParseEnum(['a','b']) (z.enum)
ParseArrayPipeParseArray(ParseInt) (split + per-item)
DefaultValuePipeDefaultValue(1, ParseInt) or just .default(1)
ValidationPipethe schema itself
a custom PipeTransform.transform(fn) / .refine(fn)

There's no multi-pipe chain syntax (@Query('x', PipeA, PipeB)) — a zod schema already composes, so the chain is one expression: @Query('activeOnly', ParseBool.default(false)) is Nest's new DefaultValuePipe(false), ParseBoolPipe. Every pipe throws on a bad value (→ 400); only DefaultValue substitutes — and only for an absent value, never an invalid one.

Two generated artifacts

From your decorated classes, roost emits (into a gitignored .roost/):

  1. Route delegates (.roost/api/**) — one tiny Nitro file per @Get/@Post/…, scanned by Nitro's normal file-based router.
  2. The DI manifest (.roost/di.generated.ts) — the awilix container.register({...}), plus a plugin that wires it at startup.

Both are real files. The dependency direction is inverted: generated files import from the runtime, and nothing in the runtime imports generated code — which is what keeps the heavy build-time dependency (ts-morph) out of your edge bundle.

The magic spectrum

LevelCodegen readsEmits
0nothingyou hand-write the route files (plain Nitro)
1 (today)@Controller/@Get/@UseGuards/@UseFilters/@UseInterceptors + @Body/@Param/@Query/@Ctx + ctor typesroute files + DI wiring + cross-cutting + param injection
2+ @Module, @Catch(Type)per-module scopes, typed exception matching
3+ zod DTOsOpenAPI, a typed $fetch client, RLS policy stubs
Copyright © 2026