If you're deploying NestJS to AWS Lambda using CDK's NodejsFunction and getting Cannot read properties of undefined errors on your injected services, the issue maybe that esbuild doesn't support TypeScript's emitDecoratorMetadata. The solution is to point your Lambda entry to pre-compiled JavaScript from nest build instead of TypeScript source files.
The Problem
I was deploying a NestJS application to AWS Lambda using AWS CDK's NodejsFunction construct. Everything worked perfectly in local development, but after deployment, then met encountered this cryptic error:
TypeError: Cannot read properties of undefined (reading 'register')
at TenantAuthController.register (/var/task/index.js:1140:40809)
The register method existed in the TenantAuthService, and the service was properly decorated with @Injectable() and registered in our module. So why was this.tenantAuthService undefined?
The Controller Code
@Controller('tenant')
export class TenantAuthController {
constructor(private readonly tenantAuthService: TenantAuthService) {}
@Post('register')
@Public()
async register(@Body() dto: TenantRegisterDto) {
return this.tenantAuthService.register(dto); // π₯ tenantAuthService is undefined!
}
}
The Module Configuration
@Module({
imports: [EventEmitterModule, MailingModule],
controllers: [TenantAuthController],
providers: [
TenantAuthService,
CognitoService,
// ... other providers
],
exports: [CognitoService, TenantAuthService],
})
export class AuthModule {}
Everything looked correct. The service was registered, the controller was declared, and it worked locally. What was going wrong?
Root Cause: esbuild Doesn't Support Decorator Metadata
AWS CDK's NodejsFunction uses esbuild under the hood for bundling TypeScript. While esbuild is incredibly fast, it has a fundamental limitation: it does not support TypeScript's emitDecoratorMetadata compiler option.
NestJS relies heavily on decorator metadata for its dependency injection system. When you write:
constructor(private readonly tenantAuthService: TenantAuthService) {}
TypeScript (with emitDecoratorMetadata: true) emits metadata that tells NestJS "this constructor parameter should be an instance of TenantAuthService". This metadata is generated using the Reflect.metadata API.
When esbuild processes your TypeScript files, it:
- β Transpiles TypeScript to JavaScript
- β
Handles decorators (the
@Injectable(),@Controller()syntax) - β Does NOT emit the reflection metadata that NestJS needs for DI
Without this metadata, NestJS cannot determine what dependencies to inject, resulting in undefined being passed to your constructors.
Our CDK Configuration (Before)
const AuthFunction = new lambdaNodejs.NodejsFunction(this, 'Function', {
runtime: lambda.Runtime.NODEJS_20_X,
entry: path.join(__dirname, '../../src/lambda.ts'), // β TypeScript source
handler: 'handler',
bundling: {
minify: true,
externalModules: [
'@aws-sdk/*',
'@nestjs/microservices',
'@nestjs/websockets',
// ...
],
},
});
The Solution: Pre-compile with nest build
The solution is elegantly simple: let NestJS compile your TypeScript first, then have esbuild bundle the pre-compiled JavaScript.
Step 1: Update Your CDK Stack
Change the entry point from .ts to the pre-compiled .js file:
const AuthFunction = new lambdaNodejs.NodejsFunction(this, 'Function', {
runtime: lambda.Runtime.NODEJS_20_X,
// β
Point to pre-compiled JS from nest build
entry: path.join(__dirname, '../../dist/lambda.js'),
handler: 'handler',
bundling: {
minify: true,
externalModules: [
'@aws-sdk/*',
'@nestjs/microservices',
'@nestjs/websockets',
'@nestjs/websockets/socket-module',
'@nestjs/microservices/microservices-module',
'class-transformer/storage',
],
},
});
Step 2: Update Your Deploy Script
Ensure nest build runs before CDK deploy:
{
"scripts": {
"deploy:dev": "nest build && cdk deploy --app \"npx ts-node cdk/bin/app.ts\" --require-approval never"
}
}
Step 3: Verify Your tsconfig.json
Ensure decorator metadata emission is enabled:
{
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"module": "commonjs",
"target": "ES2021",
"outDir": "./dist"
}
}
Why This Works
The compilation flow is now:
-
nest buildruns TypeScript compiler (tsc) with yourtsconfig.json -
tscseesemitDecoratorMetadata: trueand generates reflection metadata - Output goes to
dist/as JavaScript with metadata intact - CDK/esbuild bundles the JavaScript (not TypeScript)
- esbuild doesn't strip the already-generated metadata
- Lambda receives properly bundled code with DI metadata
- NestJS can resolve dependencies correctly
Alternative Solutions I Tried (And Why They Didn't Work)
1. preCompilation: true in Bundling Options
bundling: {
preCompilation: true, // β Causes pnpm symlink issues
}
This option tells CDK to run tsc before esbuild. However, if you're using pnpm as your package manager, this causes "ELOOP: too many symbolic links encountered" errors because the bundler follows pnpm's symlinked node_modules structure recursively.
2. Command Hooks for Pre-compilation
bundling: {
commandHooks: {
beforeBundling(inputDir: string): string[] {
return [`cd ${inputDir} && npx tsc`];
},
},
}
This approach runs tsc in a hook before bundling. The problem is that the inputDir doesn't always point where you expect, and the compiled output isn't automatically used by esbuild.
3. esbuild Plugins
The NodejsFunction construct doesn't support arbitrary esbuild plugins. Solutions like @anatine/esbuild-decorators require API access that CDK doesn't expose.
Additional Considerations
SSL Certificates for Database Connections
If your Lambda connects to RDS with SSL, you might encounter another issue: file system access for CA certificates. Lambda's bundled environment may not have your certificate files.
Solution: Embed certificates directly in your code:
const RDS_CA_CERT = `-----BEGIN CERTIFICATE-----
MIID/zCCAuegAwIBAgIRAPVSMfFitmM5Phm...
-----END CERTIFICATE-----`;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.DATABASE_SSL === 'true'
? { ca: RDS_CA_CERT, rejectUnauthorized: true }
: false,
});
External Modules
Keep these modules external to avoid bundling issues:
externalModules: [
'@aws-sdk/*', // Lambda runtime provides these
'@nestjs/microservices', // Optional NestJS modules
'@nestjs/websockets',
'@nestjs/websockets/socket-module',
'@nestjs/microservices/microservices-module',
'class-transformer/storage', // Can cause DI issues if bundled
]
Summary
| Approach | Works? | Notes |
|---|---|---|
Direct .ts entry with esbuild |
β | No decorator metadata |
preCompilation: true |
β οΈ | Breaks with pnpm |
| Command hooks | β οΈ | Complex, unreliable paths |
Pre-compiled .js entry |
β | Simple, reliable |
The key insight is that esbuild is a bundler, not a full TypeScript compiler. For frameworks like NestJS that depend on TypeScript's reflection capabilities, you need to use tsc (via nest build) first, then let esbuild handle the bundling of the resulting JavaScript.
Final Working Configuration
cdk-stack.ts:
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as path from 'path';
const nestFunction = new lambdaNodejs.NodejsFunction(this, 'NestFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
entry: path.join(__dirname, '../../dist/lambda.js'), // Pre-compiled!
handler: 'handler',
memorySize: 1024,
timeout: cdk.Duration.seconds(30),
bundling: {
minify: true,
sourceMap: false,
externalModules: [
'@aws-sdk/*',
'@nestjs/microservices',
'@nestjs/websockets',
'@nestjs/websockets/socket-module',
'@nestjs/microservices/microservices-module',
'class-transformer/storage',
],
},
});
package.json:
{
"scripts": {
"build": "nest build",
"deploy": "nest build && cdk deploy"
}
}
Top comments (0)