Core concepts
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' });@Controllerand guards/filters/interceptors by default) are built fresh per request and can inject the per-requestRequestContext. - 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 types — constructor(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:
| Decorator | Injects |
|---|---|
@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 pipe | roost |
|---|---|
ParseIntPipe | ParseInt (z.string().regex(int).transform(Number)) |
ParseFloatPipe | ParseFloat |
ParseBoolPipe | ParseBool (z.stringbool()) |
ParseUUIDPipe | ParseUUID (z.uuid()) |
ParseEnumPipe | ParseEnum(['a','b']) (z.enum) |
ParseArrayPipe | ParseArray(ParseInt) (split + per-item) |
DefaultValuePipe | DefaultValue(1, ParseInt) or just .default(1) |
ValidationPipe | the 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/):
- Route delegates (
.roost/api/**) — one tiny Nitro file per@Get/@Post/…, scanned by Nitro's normal file-based router. - The DI manifest (
.roost/di.generated.ts) — the awilixcontainer.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
| Level | Codegen reads | Emits |
|---|---|---|
| 0 | nothing | you hand-write the route files (plain Nitro) |
| 1 (today) | @Controller/@Get/@UseGuards/@UseFilters/@UseInterceptors + @Body/@Param/@Query/@Ctx + ctor types | route files + DI wiring + cross-cutting + param injection |
| 2 | + @Module, @Catch(Type) | per-module scopes, typed exception matching |
| 3 | + zod DTOs | OpenAPI, a typed $fetch client, RLS policy stubs |