TypeScript for NestJS

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.3, the version the Nest 12 CLI and templates install), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, and was checked on 2026-10-02. TypeScript 7 (the native compiler) is the current latest on npm, but nest new does not generate projects for it, so this page does not claim Nest support for it. The examples target a project generated by the Nest 12 CLI, both in the ESM layout (Vitest, "type": "module") and in the CommonJS layout (Jest); the differences are called out where they exist.

NestJS asks very little of TypeScript, but what it asks for is unusual: an older decorator implementation, a compiler option that writes type information into the emitted JavaScript, and a module setting that changes how you spell imports. Most "Nest can’t resolve dependencies" and "DTO is not validated" problems come from one of these four things. For the language itself, see the TypeScript section; this page covers only what Nest needs.

The compiler settings Nest projects use

nest new generates the following compilerOptions for an ESM project. A CommonJS project differs only in the types entry (["node", "jest"]) and in having no "type": "module" in package.json; it also uses nodenext.

tsconfig.json generated by nest new (ESM layout)
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "resolvePackageJsonExports": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "ES2023",
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": ".",
    "incremental": true,
    "skipLibCheck": true,
    "strict": true,
    "strictPropertyInitialization": false,
    "types": ["vitest/globals", "node"]
  }
}

Four groups of options matter for the rest of this page:

Option Why Nest needs it

experimentalDecorators

Turns on the legacy decorator implementation that @Controller(), @Get(), @Body() and @Injectable() are written for.

emitDecoratorMetadata

Makes the compiler record the types of decorated constructors, properties and methods, which is what Nest’s container and ValidationPipe read.

module / moduleResolution set to nodenext

Selects Node.js’s real module rules. In an ESM project this is why relative imports need a .js extension.

strict: true, strictPropertyInitialization: false

Full strict checking, except that class properties are not required to be assigned in a constructor (see Strict-mode implications).

The general meaning of each option is covered in tsconfig and compiler options and in Modules.

Legacy decorators versus Stage 3 decorators

TypeScript ships two incompatible decorator implementations. Since TypeScript 5.0 the standard TC39 (Stage 3) decorators are the default, with no flag. The older implementation, "legacy" or "experimental" decorators, stays behind experimentalDecorators. The two are explained side by side in Decorators and Metadata; the points that matter for Nest are:

  • Nest’s decorators are written for the legacy signatures: class decorators receive the constructor, method decorators receive (target, propertyKey, descriptor), and parameter decorators such as @Body(), @Param(), @Query() and @Inject() exist.

  • Standard decorators have no parameter decorators and call methods and fields with a single context argument, so Nest’s decorators do not type-check against them.

  • Standard decorators also have no counterpart of emitDecoratorMetadata; the type information Nest reads is only emitted on the legacy path.

If experimentalDecorators is missing or set to false in a Nest project, tsc rejects the code. Compiling the example controller and DTO below with "experimentalDecorators": false and "emitDecoratorMetadata": false in TypeScript 6.0.3 produced errors such as the following:

Compiler output with experimentalDecorators disabled
cats.controller.ts(9,3): error TS1241: Unable to resolve signature of method decorator when called as an expression.
  The runtime will invoke the decorator with 2 arguments, but the decorator expects 3.
create-cat.dto.ts(4,4): error TS1240: Unable to resolve signature of property decorator when called as an expression.
logger.ts(19,15): error TS1206: Decorators are not valid here.

The last error is a parameter decorator (@Inject(…​) on a constructor parameter), which Stage 3 does not allow. The practical rules are:

  • Keep experimentalDecorators and emitDecoratorMetadata enabled in every Nest project, library included.

  • Your own decorators for use with Nest (guards' metadata, custom parameter decorators) must use the legacy signatures. Prefer the helpers Nest provides, such as createParamDecorator(), SetMetadata(), Reflector.createDecorator() and applyDecorators(), because they hide the signature differences; see Custom decorators.

  • Do not mix the two models in one project. A Stage 3 decorator library (one that targets the standard signatures) will not work with Nest’s decorators, and the reverse.

emitDecoratorMetadata and reflect-metadata

With emitDecoratorMetadata enabled, TypeScript adds metadata to every declaration that carries at least one decorator. Three keys are used: design:type for properties, design:paramtypes for constructor and method parameters, and design:returntype for method return types. The metadata is stored through the Reflect.metadata API, which is not part of JavaScript: it comes from the reflect-metadata polyfill. reflect-metadata is already a dependency of a generated Nest project, and @nestjs/common and @nestjs/core import it, so you rarely import it yourself. You only need import 'reflect-metadata' when you read metadata in code that runs outside a Nest application.

Here is a service, a controller that depends on it, and what the compiler emits for the controller:

cats.service.ts and cats.controller.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class CatsService {
  findAll(): string[] {
    return ['Tom', 'Felix'];
  }
}
import { Controller, Get } from '@nestjs/common';
import { CatsService } from './cats.service.js';

@Controller('cats')
export class CatsController {
  // No @Inject(): the emitted design:paramtypes metadata names CatsService.
  constructor(private readonly catsService: CatsService) {}

  @Get()
  findAll(): string[] {
    return this.catsService.findAll();
  }
}
Emitted JavaScript for cats.controller.ts (ESM project, abridged)
__decorate([
    Get(),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", []),
    __metadata("design:returntype", Array)
], CatsController.prototype, "findAll", null);
CatsController = __decorate([
    Controller('cats'),
    __metadata("design:paramtypes", [CatsService])
], CatsController);

Reading it back in a test or script shows the same thing:

import 'reflect-metadata';
import { CatsController } from './cats.controller.js';

const paramTypes = Reflect.getMetadata('design:paramtypes', CatsController);
// [ [class CatsService] ]

Some consequences follow directly from "metadata is emitted for decorated declarations only":

  • A class with no decorator gets no design:paramtypes. That is why every injectable class needs @Injectable() (or another class decorator such as @Controller()), even when it has no other metadata.

  • A method gets parameter metadata only if the method itself has a decorator. @Get(), @Post(), @UsePipes() and @SetMetadata() all count.

  • Metadata is read at runtime from the emitted output. Anything that compiles TypeScript without honouring emitDecoratorMetadata silently breaks injection (see the pitfalls below).

How constructor types become DI tokens

When Nest instantiates a provider it reads design:paramtypes from the class and uses each entry as the token to look up in the module’s providers. That is the whole mechanism behind constructor injection without @Inject(): the class CatsService that you imported and used as a type annotation is the same class object that was registered under providers: [CatsService], so the lookup succeeds. When the type is not a class that exists at runtime, TypeScript emits Object, a union or a type alias also emits Object, and Nest has nothing to look up. In that case you tell Nest the token explicitly with @Inject(token).

Pitfalls of metadata-based injection

The following behaviours apply to TypeScript 6.0.3 and Nest 12.

Metadata disabled: injection fails silently. With emitDecoratorMetadata set to false, the code above compiles without a single error, and Nest instantiates CatsController with no arguments. The application starts, catsService is undefined, and the first request fails with Cannot read properties of undefined (reading 'findAll'). If a Nest application starts but a constructor-injected property is undefined, check that the compiler or bundler in use emits decorator metadata.

Other compilers. The Nest CLI builds with tsc by default and honours the option. Other build paths have to emulate it: the SWC builder needs decoratorMetadata enabled (see the official SWC recipe), and an esbuild-based test runner does not emit decorator metadata at all. In an ESM project, the Vitest 4.1 template runs constructor injection correctly without extra plugins, but if a test of your own shows undefined dependencies, a transformer that ignores emitDecoratorMetadata is the first suspect, and @Inject(CatsService) is the workaround.

Optional parameters: use ?, not | undefined. A union type emits Object, even when one member is undefined:

@Injectable()
export class ExampleService {
  constructor(
    @Optional() private readonly a: CatsService | undefined, // design:paramtypes entry: Object
    @Optional() private readonly b?: CatsService,            // design:paramtypes entry: CatsService
  ) {}
}

Declare optional dependencies with the ? modifier (or add an explicit @Inject(CatsService)), otherwise Nest looks up a provider whose token is Object; because the parameter is optional, the lookup failure is not reported and the property is silently undefined.

Circular imports under ESM. When two classes import each other and each uses the other as a constructor type, the emitted design:paramtypes array evaluates a class before it is initialized. In an ESM project this fails at load time, before Nest runs:

ReferenceError: Cannot access 'AService' before initialization

forwardRef() alone does not help, because the metadata array is still emitted from the type annotation. Wrap the type so that TypeScript emits Object, and give the token through @Inject(forwardRef(…​)):

// relation.ts
// Wrapping the type makes TypeScript emit `Object` instead of a class reference.
export type Relation<T> = T;
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { BService } from './b.service.js';
import type { Relation } from './relation.js';

@Injectable()
export class AService {
  constructor(
    @Inject(forwardRef(() => BService)) private readonly b: Relation<BService>,
  ) {}
}

BService mirrors this with AService. Both providers resolve once the type is wrapped. Better still, break the cycle by moving shared logic into a third provider; Module reference, circular dependencies and lazy loading discusses the options.

Classes versus interfaces as DTOs and tokens

TypeScript interfaces and type aliases are erased at compile time, while classes are kept. Nest reads metadata at runtime, so this difference decides which one you can use.

DTOs must be classes

ValidationPipe reads the parameter’s metadata type (design:paramtypes), and then uses class-validator decorators found on that class. With an interface there is no class, so the metadata type is Object and nothing is validated. Compare a class DTO with an interface:

create-cat.dto.ts
import { IsInt, IsString, Min } from 'class-validator';

export class CreateCatDto {
  @IsString()
  name!: string;

  @IsInt()
  @Min(0)
  age!: number;
}

// An interface compiles to nothing, so the metadata falls back to Object.
export interface CreateCatShape {
  name: string;
  age: number;
}
@Controller('check')
export class CheckController {
  @Post('dto')
  @UsePipes(new ValidationPipe())
  withClass(@Body() dto: CreateCatDto) {
    return dto;
  }

  @Post('shape')
  @UsePipes(new ValidationPipe())
  withInterface(@Body() dto: CreateCatShape) {
    return dto;
  }
}
curl -s -X POST localhost:3000/check/dto -H 'Content-Type: application/json' -d '{"name": 1}'
curl -s -X POST localhost:3000/check/shape -H 'Content-Type: application/json' -d '{"name": 1}'

The class DTO is rejected, the interface is not:

{
  "message": ["name must be a string", "age must not be less than 0", "age must be an integer number"],
  "error": "Bad Request",
  "statusCode": 400
}
{ "name": 1 }

The second response has status 201: the body passed through unchecked. An end-to-end test using supertest shows both behaviours. The same applies to generics and type aliases. Nest 12 still supports class-based validation with ValidationPipe, and also offers schema-based validation through Standard Schema when you would rather define the shape with a schema library; see Pipes and validation.

Source: Validation.

Import DTO classes as values

A DTO class used in a decorated signature must be a value import, because the emitted metadata refers to the class at runtime. With isolatedModules and emitDecoratorMetadata both enabled, as in the Nest template, TypeScript reports an error when a type that exists only as a type is referenced in a decorated signature but imported normally:

b.ts(6,23): error TS1272: A type referenced in a decorated signature must be imported with
'import type' or a namespace import when 'isolatedModules' and 'emitDecoratorMetadata' are enabled.

The fix for an interface or type alias is import type (or inline type in the import list), which is what the example in this page does for CreateCatShape. The reverse also holds: never use import type for a class DTO that Nest or ValidationPipe needs at runtime, or the metadata would lose the class.

import { CreateCatDto, type CreateCatShape } from './create-cat.dto.js';

Tokens for interfaces

The same erasure means an interface cannot be a provider token. Nest documents two ways around it: register the provider under a string or Symbol token and inject it with @Inject(), or use an abstract class, which is both the TypeScript contract and a real runtime value.

import { Inject, Injectable, Module } from '@nestjs/common';

export interface AppLogger {
  log(message: string): void;
}

export const APP_LOGGER = Symbol('APP_LOGGER');

@Injectable()
export class ConsoleAppLogger implements AppLogger {
  log(message: string): void {
    console.log(message);
  }
}

@Injectable()
export class ReportService {
  // Interface type + Symbol token: @Inject() is required.
  constructor(@Inject(APP_LOGGER) private readonly logger: AppLogger) {}
}

export abstract class Clock {
  abstract now(): number;
}

@Injectable()
export class FixedClock extends Clock {
  now(): number {
    return 42;
  }
}

@Injectable()
export class TimeService {
  // Abstract class: it is its own runtime token, no @Inject() needed.
  constructor(private readonly clock: Clock) {}
}

@Module({
  providers: [
    { provide: APP_LOGGER, useClass: ConsoleAppLogger },
    { provide: Clock, useClass: FixedClock },
    ReportService,
    TimeService,
  ],
})
export class UtilModule {}

Both ReportService and TimeService resolve (an end-to-end test that calls both receives the expected values). Prefer a Symbol for library code, where a string token could collide with an unrelated provider, and an abstract class when you want simple constructor injection. The full set of provider forms is covered in Providers and dependency injection.

ESM .js import extensions under nodenext

With "module": "nodenext", TypeScript decides whether each file is ESM or CommonJS from the nearest package.json. A project with "type": "module" is ESM, and Node.js’s ESM loader does not guess file extensions, so relative imports must name the emitted file. The extension is .js even though the source file is .ts, because the specifier refers to the output.

// Before (CommonJS)
import { AppModule } from './app.module';

// After (ESM)
import { AppModule } from './app.module.js';

Leaving the extension out of an ESM file is a compile-time error:

a.ts(1,29): error TS2307: Cannot find module '../typescript-for-nestjs/cats.service' or its corresponding type declarations.

The rest of the ESM switch is small but real:

  • Core @nestjs/* packages are ESM-only in v12, and CommonJS applications can consume them through require(esm) on current Node.js releases. Migrating your own application to ESM is optional; nest upgrade leaves your module format alone.

  • dirname and filename do not exist in ESM; use import.meta.dirname, or import.meta.url with fileURLToPath on older Node.js versions. require() is unavailable unless you build it with createRequire(import.meta.url).

  • Adding "type": "module" changes how every .ts file is emitted, so fix the extensions in one pass rather than file by file.

  • nest new asks whether to generate a CommonJS or an ESM project. ESM projects use Vitest, CommonJS projects use Jest.

  • In a CommonJS project the same nodenext settings work, but relative imports need no extension (a CommonJS project builds and runs its tests without any), and package.json has no "type": "module". The generated Nest samples in the documentation use ESM spellings, so when you copy one into a CommonJS project, drop the .js extensions.

Source: Migration guide, "Moving your own code to ESM". See also TypeScript modules for how nodenext resolves imports.

Strict-mode implications

The Nest template enables strict (so strictNullChecks, noImplicitAny, strictFunctionTypes and the rest) but turns strictPropertyInitialization off. This one exception exists because of how DTOs and entities are written: properties are filled in by the framework (ValidationPipe, class-transformer, an ORM), never by a constructor, so TypeScript’s definite-assignment check cannot see the assignment.

If you enable strictPropertyInitialization (or start from a plain strict config), every DTO property raises an error:

c.ts(2,3): error TS2564: Property 'name' has no initializer and is not definitely assigned in the constructor.

The usual answers, in order of preference:

  • Keep strictPropertyInitialization off if the project is mostly DTOs and entities, as the template does.

  • Otherwise use the definite assignment assertion, name!: string, as in CreateCatDto above. It says "the framework assigns this", and it has no runtime effect or metadata effect: design:type is still String.

  • Mark properties that a client may omit as optional (nickname?: string) and decorate them with @IsOptional(), so the type and the validation rule agree.

Two other consequences of strict mode are worth knowing:

  • With target: "ES2023", class fields use define semantics, so the compiled DTO contains field declarations such as name;. This is harmless for DTOs, which are populated after construction.

  • strictNullChecks is why x?: Type and Type | undefined behave differently as constructor parameters; see the optional-parameter pitfall above.

Source: TypeScript, strictPropertyInitialization. The effect of each strict-family option is covered in strict and its members.

What changed recently

  • Nest 12 packages are ESM-only. A CommonJS application keeps working through require(esm), but Jest can load them only on Node.js 24.9 or later.

  • nest new offers CommonJS or ESM. Projects generated by a recent v11 CLI (@nestjs/schematics 11.0.6 or later) already use nodenext; earlier ones typically have "module": "commonjs" and need both module and moduleResolution updated before adding "type": "module".

  • TypeScript 6 is what the Nest 12 CLI and the generated project use; nest upgrade bumps a project to it.

  • Standard Schema validation (the schema option on @Body(), @Query() and @Param(), and StandardSchemaValidationPipe) lets you validate a plain schema instead of a decorated class, in which case the class-versus-interface limitation above does not apply. See What’s changed and migration.

Source: Migration guide.

Where the book is outdated

The reference book, published in January 2025 (see the section bibliography), predates Nest 12 and its ESM-only packages, so its project setup reflects the CommonJS-era defaults. Where you follow it:

  • Expect a CommonJS project with "module": "commonjs"; the current template uses nodenext for both layouts, and an ESM project needs .js extensions on relative imports.

  • Jest is no longer the default for new ESM projects; Vitest is.

  • The decorator and metadata model it relies on (legacy decorators, emitDecoratorMetadata, class tokens) is unchanged and still correct.