DEV Community

Cover image for NestJS 12: @Optional() Not Inherited by a Redeclared Constructor
Parsa J
Parsa J

Posted on Originally published at bestpractic.org

NestJS 12: @Optional() Not Inherited by a Redeclared Constructor

A NestJS 11 → 12 upgrade runs clean. npm install, nest build, no TypeScript errors, no deprecation warnings. Then the app won't boot: Error: Nest can't resolve dependencies of the TenantApiKeyGuard (?). Please make sure that the argument at index [0] is available in the AuthModule context. The guard in question has one recent change: a constructor was added to it last sprint, purely to forward an already-optional option down to its parent. Nothing about the dependency itself changed — it's the same parameter, with the same intent, that's been optional since the base class was written.

This is written against @nestjs/core 12.1.2 (verified 2026-10-06 via npm view @nestjs/core dist-tags; v12.0.0 shipped 2026-08-27, and the 11.x line is now on the legacy npm tag at 11.2.7). The behavior below is documented in NestJS's own migration guide, under "@Optional() is no longer inherited by a redeclared constructor" — a deliberate v12 change, not a bug. One detail matters enough to state up front, because it inverts the obvious guess: a subclass with no constructor of its own is not affected. Only a subclass that declares its own constructor loses the inherited marker. (Versions 12.0.0–12.0.4 briefly broke the childless case too — more on that below — but that was its own regression, fixed in 12.1.0, not the rule going forward.) It is unrelated to the v12 ESM migration or the Standard Schema validation feature covered in an earlier episode of this series — this is purely DI-container behavior.

What you'll learn

By the end of this article you'll be able to:

  • Explain exactly what changed in how NestJS 12 reads @Optional() metadata on constructor parameters, and why it's specifically tied to which class declares the constructor, not to "the subclass" in general
  • Correctly predict that a childless subclass (class X extends Y {}) is safe, and that the risk starts the moment that subclass gets a constructor of its own
  • Read an UnknownDependenciesException that names a subclass with its own constructor, and know that the fix is re-adding @Optional() there, not removing the constructor
  • Fix an affected subclass correctly — and explain why "it compiled before" isn't evidence it was ever safe
  • Decide, for a new subclass you're writing today, whether adding a constructor to it needs a @Optional() audit first

Who this is for

You've written at least one NestJS provider or guard with a constructor-injected dependency, and you know roughly what @Injectable() and @Optional() do. You don't need any TypeScript decorator metaprogramming experience — this article builds the one piece of reflect-metadata behavior that matters, from nothing.

Table of contents

The problem: a guard that worked in v11 throws on v12

Here's a base guard, trimmed to the part that matters. It optionally takes module options — if none were provided, it falls back to defaults:

// base-api-key.guard.ts
@Injectable()
export class BaseApiKeyGuard implements CanActivate {
  constructor(@Optional() protected options?: ApiKeyModuleOptions) {}

  canActivate(context: ExecutionContext): boolean {
    const header = this.options?.headerName ?? 'x-api-key';
    // ...validate the request against `header`
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

A per-feature guard extends it. Originally it was a one-liner, written only to get a distinct DI token:

// tenant-api-key.guard.ts
@Injectable()
export class TenantApiKeyGuard extends BaseApiKeyGuard {}
Enter fullscreen mode Exit fullscreen mode

That version — no constructor at all — is fine on both v11 and v12. The trouble starts when someone later adds a constructor to TenantApiKeyGuard, for an ordinary reason: forwarding the same option explicitly, adding a log line, or preparing to inject a second collaborator next sprint.

// tenant-api-key.guard.ts, after a routine-looking change
@Injectable()
export class TenantApiKeyGuard extends BaseApiKeyGuard {
  constructor(options?: ApiKeyModuleOptions) {
    super(options);
  }
}
Enter fullscreen mode Exit fullscreen mode

ApiKeyModuleOptions isn't registered as a provider anywhere (there's no module-wide config object, by design — it's optional), and on NestJS 11 this is unremarkable: the app boots fine. On NestJS 12, with no further changes, bootstrap now fails:

Error: Nest can't resolve dependencies of the TenantApiKeyGuard (?).
Please make sure that the argument at index [0] is available in the AuthModule context.
Enter fullscreen mode Exit fullscreen mode

The constructor added to TenantApiKeyGuard looks harmless — it just forwards the same parameter to super(). But forwarding it isn't the same as marking it. The @Optional() call that made this parameter optional lives only on BaseApiKeyGuard, and TenantApiKeyGuard just started declaring its own constructor without repeating it.

The mental model: the optional marker belongs to whichever class declares the constructor

The mental model: a TypeScript decorator like @Optional() doesn't attach a property to an instance, and it doesn't attach to "the class" in some abstract sense — it calls Reflect.defineMetadata(key, data, target) on one specific constructor function, at the moment that constructor's parameter list is compiled. BaseApiKeyGuard's own constructor is decorated with @Optional(), so the marker for "parameter 0 is optional" is defined on BaseApiKeyGuard itself.

Whether a lookup later finds that metadata from a subclass depends on which reflect-metadata method does the reading, because the two behave differently with respect to the prototype chain — and, as of NestJS 12.1, on which class actually owns the constructor being resolved:

  • Reflect.getMetadata(key, target) walks up the prototype chain. NestJS still uses this for parameter types (design:paramtypes), so a subclass — with or without its own constructor — always sees the correct inherited type.
  • Reflect.getOwnMetadata(key, target) does not walk the chain. NestJS 12 uses this for @Optional() markers, but it's called on the class that declares the constructor being resolved, not blindly on the exact subclass. For a class with no constructor of its own, that owner is the nearest ancestor that does declare one — so the marker is still found. For a class that declares its own constructor, that owner is the class itself — so a marker that exists only on the parent is not found.

NestJS's own migration guide states it this way: "Nest now reads the optional markers of constructor parameters from the class that declares the constructor. A subclass that declares its own constructor no longer inherits the markers of its parent's constructor... Starting with NestJS v12.1, a subclass without its own constructor keeps the optional markers of its parent's constructor, as in v11." Parameter types are unaffected either way — they're read through the separate, always-chain-walking design:paramtypes mechanism — so Nest still correctly sees that TenantApiKeyGuard takes one constructor argument of type ApiKeyModuleOptions; once that subclass has its own constructor, it just no longer sees that argument as optional.

Key concept: the same two facts — "this parameter exists" and "this parameter is optional" — are stored as two different kinds of metadata, read with two different reflect-metadata calls. Declaring your own constructor doesn't change where parameter types come from. It does change where the optional marker is looked up: from "wherever an ancestor happened to define it" to "the class that now owns this constructor" — which, now that you've written one, is you.

Stage 1: a base class with one optional dependency

Start from the smallest version of the base guard, with nothing inherited yet:

@Injectable()
export class BaseApiKeyGuard implements CanActivate {
  constructor(@Optional() protected options?: ApiKeyModuleOptions) {}

  canActivate(): boolean {
    return Boolean(this.options?.headerName ?? 'x-api-key');
  }
}
Enter fullscreen mode Exit fullscreen mode

Used directly (@UseGuards(BaseApiKeyGuard)), this behaves identically in v11 and v12: @Optional() metadata is defined on BaseApiKeyGuard itself, and whatever reads it — chain-walking or not — finds it right there, no prototype walk required.

Stage 2: the childless subclass — safe in v11 and v12

Add the subclass that exists purely to give the feature module its own injectable token:

@Injectable()
export class TenantApiKeyGuard extends BaseApiKeyGuard {}
Enter fullscreen mode Exit fullscreen mode

No constructor, no new behavior — this is the entire class body. Register it in a module, inject it with @UseGuards(TenantApiKeyGuard), and this boots normally on both NestJS 11 and NestJS 12.1+. TenantApiKeyGuard has no constructor of its own, so Nest's lookup for the optional marker walks up to find the nearest class that does declare one — BaseApiKeyGuard — and reads the marker from there. This is exactly the common pattern you'll recognize from @nestjs/passport, class JwtAuthGuard extends AuthGuard('jwt') {}, and it was never the trap here.

(For a few weeks, it was: NestJS 12.0.0 through 12.0.4 read the optional marker directly off the exact subclass with no such fallback, which broke this childless case too. That was a regression against a widely-used pattern, not the intended design, and it was fixed in 12.1.0. If you're on an exact 12.0.x pin, this stage can fail for you — upgrading to 12.1+ is the fix, not changing your class.)

Stage 3: add a constructor, lose the marker

Now give TenantApiKeyGuard a constructor — for a reason as ordinary as forwarding the same parameter explicitly:

@Injectable()
export class TenantApiKeyGuard extends BaseApiKeyGuard {
  constructor(options?: ApiKeyModuleOptions) {
    super(options);
  }
}
Enter fullscreen mode Exit fullscreen mode

No behavior changed. The parameter is still forwarded to the exact same place. But bootstrap now throws on NestJS 12 (12.1+, the version this article targets):

Error: Nest can't resolve dependencies of the TenantApiKeyGuard (?).
Please make sure that the argument at index [0] is available in the AuthModule context.
Enter fullscreen mode Exit fullscreen mode

Per the mental model above: TenantApiKeyGuard now declares its own constructor, so it is the class the optional-marker lookup reads from — and it never had @Optional() called on its own constructor's parameter. The parameter type is still inherited (Nest knows it's an ApiKeyModuleOptions), but the optional flag is not. A parameter that looks required, with no provider for ApiKeyModuleOptions anywhere in AuthModule, is an unresolvable dependency, which UnknownDependenciesException reports.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Stage 4: the fix, and why it's deliberate

The fix is to re-declare @Optional() on the subclass's own constructor parameter:

@Injectable()
export class TenantApiKeyGuard extends BaseApiKeyGuard {
  constructor(@Optional() options?: ApiKeyModuleOptions) {
    super(options);
  }
}
Enter fullscreen mode Exit fullscreen mode

This works on v12.1+ (and still works on v11) because the parameter decorator now runs on TenantApiKeyGuard directly — the lookup no longer needs to find an ancestor's marker, because the marker is right there on the class that owns the constructor.

It's worth taking the migration guide's own framing seriously here: "This is deliberate: a dependency that was genuinely missing used to resolve to undefined silently." Before this change, once you added a constructor to a subclass and simply forgot to re-mark a forwarded parameter, the mistake was invisible — the old chain-walking behavior would still find the ancestor's marker and quietly pass undefined, with nothing in the logs to say the subclass's own constructor made no promise about that parameter at all. UnknownDependenciesException is a less convenient failure mode than silent undefined, but it's a more honest one.

Edge cases and gotchas

  • The break is specific to a subclass that declares its own constructor. A childless subclass — the common class X extends Y {} or class JwtAuthGuard extends AuthGuard('jwt') {} shape — is unaffected on 12.1+.
  • This is narrower than "subclassing is risky." Regular method inheritance, property inheritance, and instanceof checks are untouched. Only the DI container's optional-parameter lookup changed, and only once a subclass's own constructor exists.
  • Multi-level inheritance follows the same rule, not a worse one. If GrandchildGuard extends TenantApiKeyGuard extends BaseApiKeyGuard, and only TenantApiKeyGuard declares a constructor, a childless GrandchildGuard is safe — the lookup walks past it to TenantApiKeyGuard, the nearest class that owns a constructor, and reads the marker there (not from BaseApiKeyGuard, which is one link further).
  • The 12.0.0–12.0.4 window is a real but temporary trap. If you're reading an older blog post, Stack Overflow answer, or internal wiki page that describes the childless subclass as the broken case, it was likely written against one of those four patch releases, where that was briefly true. The documented, intended behavior since 12.1.0 is the one in this article.
  • Node.js version matters for the same upgrade. NestJS 12 requires Node.js v20.19+ or v22.12+ (per the official migration guide's Node.js requirements) because the ESM-packaged core relies on Node's require(esm) interop — unrelated to this specific bug, but the same upgrade that triggers it will also fail outright on an older Node runtime, which is worth ruling out first if bootstrap errors look different from the one shown here.
  • This isn't limited to guards. Any @Injectable() subclass — a repository base class, a strategy base class, an interceptor base class — that adds a constructor forwarding an optional parameter from its parent without redeclaring @Optional() has the identical exposure.

Best practices

  • Grep for recently-added constructors on subclasses of your own injectable base classes, especially after a v12 upgrade: any class X extends Y { constructor(...) { super(...); } } where Y's constructor has an @Optional() parameter, and X's constructor doesn't repeat it, is a candidate.
  • Treat "adding a constructor to a subclass" as a checkpoint, not a no-op. The moment a subclass gets its own constructor, every parameter it forwards from the parent needs its own decorators re-declared — optional markers included — because the subclass is now the class Nest reads them from.
  • Prefer composition over inheritance for DI-bearing base classes where you can. A TenantApiKeyGuard that takes a BaseApiKeyGuard as an injected collaborator, rather than extending it, never has this problem to begin with, because there's no constructor-ownership handoff for @Optional() metadata to trip over.
  • Run nest upgrade when you move to v12 — the CLI's upgrade command is built to flag this class of change, and catching it at upgrade time is cheaper than catching it at a production boot failure.
  • Don't "fix" this by making the dependency required instead. That trades a confusing error for a real one at the cost of forcing every consumer to provide ApiKeyModuleOptions, even ones that were correctly relying on the default. Redeclare @Optional(), don't remove it.
  • Pin to 12.1.0 or later, not an exact 12.0.x release, if you're locking a specific NestJS 12 version. The childless-subclass regression only existed in 12.0.0–12.0.4.

FAQ

Does this affect property injection (@Optional() @Inject() someProp), or only constructor parameters?

Constructor parameters are the mechanism this article covers, and it's also the one the migration guide documents. Property-based @Optional() injection is read through a different metadata key, and a subclass redeclaring its own @Optional() property does not lose the parent's separately-declared optional properties — that inheritance was specifically preserved in a later 12.1.x patch. Still, verify your own case against the current docs before assuming the two injection styles behave identically.

Is @Optional() metadata handling "broken," or is this intentional?

Intentional, per NestJS's own migration guide, for the case it targets: a subclass that declares its own constructor no longer silently inherits the parent's optional markers. The previous (v11) behavior let a parameter that genuinely had no provider resolve to undefined without any signal, as long as some ancestor class had marked it optional — regardless of whether the subclass's own constructor had any business making that same promise. The v12.1+ behavior surfaces that case as an explicit UnknownDependenciesException instead, while still correctly treating a childless subclass as making no promises of its own to override.

Will nest upgrade fix this for me automatically?

The nest upgrade command (new in the v12 CLI) is designed to apply migration steps and flag changes like this one for review, but a decorator-level fix — re-declaring @Optional() on a subclass's own constructor — is a code change specific to your class hierarchy. Treat the tooling as a way to find affected classes faster, not as something that edits them for you unsupervised.

If I just added a constructor to log something, with no new dependencies, do I still need to redeclare @Optional()?

Yes, if that constructor forwards a parameter the parent marked optional. The trigger isn't "you added a new dependency" — it's "this subclass now has a constructor at all." Even a constructor that only forwards existing parameters and adds a console.log moves the optional-marker lookup to the subclass itself.

Cheat sheet

Situation v11 v12 (12.1+)
Subclass has no constructor of its own Inherited @Optional() applies Inherited @Optional() still applies (fixed in 12.1.0; briefly broken only in 12.0.0–12.0.4)
Subclass declares its own constructor without redeclaring @Optional() Inherited @Optional() still applies Inherited @Optional() is ignored — parameter looks required
Subclass declares its own constructor and redeclares @Optional() Works Works
Parameter types across the subclass boundary Inherited Still inherited (unchanged)
// The pattern to use any time you give a subclass its own constructor
// that forwards a parameter the parent class marked @Optional().
@Injectable()
export class TenantApiKeyGuard extends BaseApiKeyGuard {
  constructor(@Optional() options?: ApiKeyModuleOptions) {
    super(options); // forward it — don't just swallow the param
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Reflect.getOwnMetadata(key, Target) → own metadata only, no prototype walk.
  • Reflect.getMetadata(key, Target) → walks the prototype chain.
  • NestJS 12.1+ reads @Optional() constructor markers from whichever class declares the constructor being resolved (getOwnMetadata on that owner) — not blindly from the literal subclass. Parameter types still use the chain-walking getMetadata.
  • A childless subclass is safe. A subclass with its own constructor must redeclare @Optional() for every forwarded optional parameter.
  • Requires Node.js v20.19+ or v22.12+.
  • Verified against @nestjs/core 12.1.2 — re-check npm view @nestjs/core dist-tags before relying on a patch-version detail, and avoid pinning an exact 12.0.x release.

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Key takeaways

  • NestJS 12.1+ reads @Optional() constructor markers from whichever class declares the constructor being resolved, using Reflect.getOwnMetadata on that owner — not blindly from the exact subclass instance.
  • A subclass with no constructor of its own still inherits the marker correctly (the lookup walks up to find the owner); a subclass that declares its own constructor does not, unless it redeclares @Optional() itself.
  • Parameter types are read differently and are still inherited either way, so Nest still sees the parameter — it just no longer sees it as optional once the subclass owns its own constructor, which is why the error is "can't resolve," not "unknown parameter."
  • NestJS 12.0.0–12.0.4 briefly broke the childless-subclass case too, as a regression; that was fixed in 12.1.0 and is not the intended or current rule.
  • The fix is always the same shape: redeclare @Optional() on the subclass's own constructor and forward to super().

A guard whose only recent change was a constructor added purely to forward an existing parameter — that's what made the TenantApiKeyGuard error so disorienting to debug the first time, because the instinct is to suspect the new code, not the fact that adding a constructor at all moved where Nest looks for an old marker. Once you know the optional marker now travels with whoever owns the constructor, it stops being mysterious: audit every injectable subclass the moment it gains a constructor of its own. Have you hit this one yet on a v12 upgrade — and did your first guess point at the right class?

📚 Read next


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)