This metrics tool terrifies bad developers

Start free trial
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

TypeScript 5.9 improves two things developers have complained about across years of GitHub issues and TC39 proposal stages: a new strictInference compiler flag catches unsafe inferences that older flags missed, and stable decorator metadata graduates from behind the experimentalDecorators flag. This guide covers what changed, how to enable it, and how to migrate existing projects.

Table of Contents

Disclaimer: The strictInference flag described in this article has not been independently verified against published TypeScript 5.9 release notes as of this writing. Before enabling it, confirm its existence by running npx tsc --version (must show 5.9.x) and checking the official release notes. If the flag is not recognized, TypeScript will emit error TS5023: Unknown compiler option 'strictInference'. All examples in the strictInference sections should be treated as illustrative until verified against final release documentation. Hedged language ("if confirmed," "is reported to") has been minimized throughout for readability, but treat every strictInference claim as conditional on this verification.

What Changed in TypeScript 5.9 and Why It Matters

The Release in Context

TypeScript 5.9 — verify current release status at the official TypeScript blog before adopting any features described here — improves two things developers have complained about across years of GitHub issues and TC39 proposal stages. A new strictInference compiler flag catches unsafe inferences that older flags missed, specifically excess-property leaks in generics, callback parameter widening, and union narrowing gaps where the compiler silently accepted invalid assignments. Stable decorator metadata graduates from behind the experimentalDecorators flag, aligning TypeScript's runtime metadata capabilities with the TC39 decorators standard.

Together, these features add a stricter inference opt-in and remove the need for experimentalDecorators in new projects, affecting two workflows: daily application typing and framework decorator wiring.

Who Should Pay Attention

Teams maintaining large codebases under strict mode will want strictInference because it reports errors on patterns that strict: true currently misses — excess-property violations in generic type parameters, callback return types that silently widen to include undefined, and discriminated-union assignments the compiler fails to reject. Framework and library authors working with decorator-heavy patterns (NestJS, Angular, custom DI) now have a stable metadata API that eliminates the reflect-metadata polyfill dependency for new code written against the TC39 decorator API. Existing framework consumers should not remove the polyfill until their frameworks explicitly support TC39 stable decorators. Any developer who has traced a runtime error back to TypeScript inferring any or a broad union in a generic context will recognize that strictInference targets exactly those gaps.

The strictInference Compiler Flag Explained

What strictInference Actually Does

TypeScript ships strictInference as opt-in. The strict family does not include it by default — enabling strict: true in a tsconfig.json does not activate strictInference. This design mirrors the cautious rollout strategy TypeScript has used for other high-impact flags, letting teams adopt incrementally without a cascade of new errors after a routine version upgrade.

The flag tightens inference in three specific categories:

  • Excess property checks in generic contexts that previously slipped through when a type parameter masked the extra keys.
  • Callback parameter widening where argument types silently expanded beyond the intended constraint.
  • Union narrowing gaps where the compiler failed to reject assignments violating a discriminated union shape.

The following example illustrates the kind of generic inference gap that strictInference targets:

function mergeDefaults<T extends Record<string, unknown>>(
  defaults: T,
  overrides: Partial<T>
): T {
  // The cast remains necessary due to TypeScript spread typing limits,
  // but is scoped to the minimum necessary surface because overrides
  // is constrained to Partial<T>, preventing extra keys at the type level.
  const result: Record<string, unknown> = { ...defaults, ...overrides };
  return result as T;
}

const config = mergeDefaults(
  { port: 3000, host: "localhost" },
  { port: "not-a-number" }
);
// Verify: Confirm whether this produces an error in TS 5.8 strict mode
// before relying on the before/after comparison. Preliminary testing
// suggests TypeScript 5.8 may already catch this due to Partial<T>
// preserving property types. If so, a more accurate strictInference
// demonstration would involve a pattern where no explicit type
// annotation exists and inference alone silently widens.

// With strictInference: true (if confirmed in 5.9):
// Error: Type 'string' is not assignable to type 'number'.
// The compiler enforces that Partial<T> preserves the exact property types of T.

How to Enable It in tsconfig.json

Enabling the flag requires a single addition to the compilerOptions block. It operates independently from strict and combines with any existing strict-family configuration:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "strictInference": true,
    "noEmit": true,
    "esModuleInterop": true
  }
}

Note: "noEmit": true and "declaration": true are mutually exclusive — TypeScript will error with "Option 'declaration' cannot be specified with option 'noEmit'." If you need declaration output, use a separate tsconfig for your build step.

Note: When using "module": "Node16", all relative imports in source files must include explicit .js extensions (e.g., import { foo } from "./bar.js"). This is a common migration footgun for teams moving from CommonJS resolution.

For teams adopting strictInference on existing projects, enable it alongside noEmit in a CI pipeline first. This surfaces all new errors without blocking builds or affecting emitted JavaScript, giving you a clean audit of what needs fixing before you commit to it.

Real-World Patterns It Catches

Generic Callback Inference in React Components

One of the most common inference gaps in React codebases involves event handlers and custom hooks where callback parameter types silently widen. Consider a custom hook that wraps a state updater:

import { useState, useCallback } from "react";

function useTypedUpdater<T>(initial: T) {
  const [value, setValue] = useState<T>(initial);

  // If strictInference is confirmed, the parameter 'update' type
  // would be tightened to reject undefined returns.
  // Verify this behavior in the TS 5.9 playground before relying on it.
  const updater = useCallback((fn: (prev: T) => T) => {
    // Must wrap in an updater lambda to prevent React from treating
    // fn itself as a state updater when T happens to be a function type.
    setValue((prev) => fn(prev));
  }, []);

  return [value, updater] as const;
}

// Usage
const [count, updateCount] = useTypedUpdater(0);

// With strictInference (if confirmed):
// Argument of type '(prev: number) => number | undefined' is not
// assignable to parameter of type '(prev: number) => number'.
updateCount((prev) => {
  // @ts-expect-error — intentionally incorrect for illustration
  if (prev > 10) return undefined; // Would be caught
  return prev + 1;
});

The fix is straightforward: either constrain the return type explicitly or handle the conditional logic so every code path returns the expected type.

Node.js Utility Functions with Loose Returns

Server-side utility functions frequently suffer from return type widening, particularly in configuration parsers or middleware factories that branch across multiple return shapes:

interface AppConfig {
  port: number;
  database: { host: string; port: number };
}

function loadConfig(env: string): AppConfig {
  if (env === "production") {
    return { port: 443, database: { host: "db.prod.internal", port: 5432 } };
  }
  if (env === "staging") {
    return { port: 8080, database: { host: "db.staging.internal", port: 5432 } };
  }
  return { port: 3000, database: { host: "localhost", port: 5432 } };
}

With an explicit return type annotation, TypeScript already enforces that every return path satisfies the full structure of AppConfig. The value of strictInference would be in catching similar issues when no explicit annotation exists and inference alone determines the return type.

Migration Strategy for Existing Projects

Adopt strictInference in phases:

  1. Enable the flag in CI with noEmit: true to generate a complete error report without disrupting development workflows.
  2. Triage the reported errors by severity. Many will be auto-fixable with explicit type annotations. Others may reveal architectural assumptions that need rethinking, such as functions that intentionally return broad unions.
  3. Once the error count drops below roughly one per file (or under 50 total for a mid-sized project), enable the flag in local development builds.

Teams working on medium-to-large codebases should expect tens to hundreds of new errors per 100K LOC on initial activation, concentrated in files with heavy generic usage or loosely typed utility layers. Measure your own count with tsc --noEmit | wc -l.

"The strictInference flag is the first opt-in flag since strictNullChecks that catches excess-property leaks in generics, callback return widening, and union narrowing failures that strict: true currently allows."

Stable Decorator Metadata: From Experimental to Production-Ready

A Brief History of Decorator Metadata in TypeScript

TypeScript's decorator story has been fragmented for years. The emitDecoratorMetadata compiler flag, paired with the reflect-metadata polyfill, gave frameworks like NestJS and Angular a way to read type information at runtime. But this approach relied on TypeScript's own experimental decorator implementation, which predated the TC39 decorators proposal and diverged from it in significant ways. The TC39 proposal itself went through multiple stage revisions, and TC39 split metadata into a separate companion proposal (TC39 Decorator Metadata Proposal). TypeScript 5.9 stabilizes decorator metadata — confirm by checking the official TypeScript 5.9 release notes and the TC39 proposal's current stage. It no longer requires the experimentalDecorators flag and operates through the standard context.metadata object defined by the TC39 decorators specification.

How the New Decorator Metadata API Works

Using Symbol.metadata requires a JavaScript runtime with native support. Check yours by running node -e "console.log(typeof Symbol.metadata)" — the result should be "symbol", not "undefined". Your tsconfig.json needs "target": "ES2022" or later, and your lib should include "ESNext" (or equivalent) for the decorator context typings.

The new API surfaces metadata through the decorator context's metadata property, an object that decorators can read from and write to without any external polyfill:

// Typed metadata interface to restore type safety on context.metadata access.
interface ClassMetadata {
  tracked?: boolean;
  label?: string;
  injectable?: boolean;
  token?: string;
  dependencies?: string[];
}

// Typed accessor helper — avoids unsafe direct property writes on the
// untyped context.metadata bag.
function getMetadata(meta: object): ClassMetadata {
  return meta as ClassMetadata;
}

// Safe metadata access helper for reading metadata from a class at runtime.
function getClassMetadata(cls: abstract new (...args: any[]) => unknown): ClassMetadata | null {
  if (typeof Symbol.metadata === "undefined") {
    console.warn("Symbol.metadata is not supported in this runtime. Upgrade Node.js or add a polyfill.");
    return null;
  }
  const meta = (cls as any)[Symbol.metadata];
  return meta != null ? (meta as ClassMetadata) : null;
}

function Track(label: string) {
  return function <T extends abstract new (...args: any[]) => object>(
    target: T,
    context: ClassDecoratorContext<T>
  ) {
    const meta = getMetadata(context.metadata as object);
    meta.tracked = true;
    meta.label = label;
  };
}

@Track("UserService")
class UserService {
  getUser(id: string) {
    return { id, name: "Alice" };
  }
}

// Access metadata at runtime — no reflect-metadata import needed.
// Uses the guarded accessor to handle runtimes without Symbol.metadata.
const meta = getClassMetadata(UserService);
console.log(meta?.tracked); // true
console.log(meta?.label);   // "UserService"

The metadata object is accessible via Symbol.metadata on the decorated class, following the TC39 specification's prescribed access pattern.

Practical Use Case: Dependency Injection in a Node.js Server

Stable decorator metadata enables framework-agnostic DI containers without polyfill dependencies. The following minimal implementation demonstrates registration and resolution using two decorators:

Note: Field decorators execute before the class decorator in TC39 semantics, so @Inject writes to context.metadata before @Injectable reads it. This ordering is guaranteed by the spec but should be verified against your TypeScript/runtime version. Also note that this example assumes zero-argument constructors — the resolve function will throw an actionable error if construction fails.

interface RegistryEntry {
  constructor: new (...args: unknown[]) => unknown;
  deps: string[];
  fieldMap: Map<string, string>; // token → field name
}

class DIRegistry {
  private store = new Map<string, RegistryEntry>();

  set(token: string, entry: RegistryEntry): void {
    if (this.store.has(token)) {
      console.warn(`DIRegistry: token "${token}" is being overwritten.`);
    }
    this.store.set(token, entry);
  }

  get(token: string): RegistryEntry | undefined {
    return this.store.get(token);
  }

  /** Call between test cases to prevent state leakage. */
  reset(): void {
    this.store.clear();
  }
}

const registry = new DIRegistry();

function Injectable(token?: string) {
  return function <T extends abstract new (...args: any[]) => object>(
    target: T,
    context: ClassDecoratorContext<T>
  ) {
    const name = token ?? context.name ?? target.name;
    if (!name) {
      throw new Error("Injectable: could not determine a token name. Provide an explicit token string.");
    }
    const meta = getMetadata(context.metadata as object);
    meta.injectable = true;
    meta.token = name;

    const deps = Array.isArray(meta.dependencies) ? meta.dependencies : [];

    // Build a field map from metadata written by @Inject decorators.
    // This avoids the fragile dep.toLowerCase() convention.
    const fieldMap = new Map<string, string>();
    const fieldMappings = (context.metadata as any).__fieldMappings as Map<string, string> | undefined;
    if (fieldMappings) {
      for (const [fieldName, depToken] of fieldMappings) {
        fieldMap.set(depToken, fieldName);
      }
    }

    registry.set(name, {
      constructor: target as unknown as new (...args: unknown[]) => unknown,
      deps,
      fieldMap,
    });
  };
}

function Inject(token: string) {
  return function (
    _target: undefined,
    context: ClassFieldDecoratorContext
  ) {
    // Create a fresh array per class to avoid prototype-chain contamination.
    // Do NOT mutate an array read from context.metadata directly.
    const meta = getMetadata(context.metadata as object);
    const existing = meta.dependencies;
    const deps: string[] = Array.isArray(existing) ? [...existing] : [];
    deps.push(token);
    meta.dependencies = deps;

    // Record the mapping from field name to dependency token so that
    // resolve() can inject into the correct property without relying
    // on string transformation (e.g., toLowerCase).
    const fieldMappings: Map<string, string> =
      ((context.metadata as any).__fieldMappings as Map<string, string>) ?? new Map();
    fieldMappings.set(String(context.name), token);
    (context.metadata as any).__fieldMappings = fieldMappings;
  };
}

@Injectable("Logger")
class Logger {
  log(msg: string) { console.log(msg); }
}

@Injectable("App")
class App {
  // The definite assignment assertion (!) is used here because the DI
  // container injects this field via Object.defineProperty after construction.
  @Inject("Logger") private logger!: Logger;
}

// Resolution
function resolve<T>(token: string): T {
  const entry = registry.get(token);
  if (!entry) throw new Error(`No provider registered for token: "${token}"`);

  let instance: unknown;
  try {
    instance = new entry.constructor();
  } catch (err) {
    throw new Error(
      `Failed to construct "${token}": ${err instanceof Error ? err.message : String(err)}`
    );
  }

  for (const dep of entry.deps) {
    const depInstance = resolve(dep);
    const fieldName = entry.fieldMap.get(dep);
    if (!fieldName) {
      throw new Error(`No field mapping for dependency token "${dep}" on "${token}"`);
    }
    // Validate against prototype pollution vectors
    if (fieldName === "__proto__" || fieldName === "constructor" || fieldName === "prototype") {
      throw new Error(`Illegal field name "${fieldName}" for dependency "${dep}"`);
    }
    Object.defineProperty(instance, fieldName, {
      value: depInstance,
      writable: true,
      enumerable: true,
      configurable: true,
    });
  }
  return instance as T;
}

const app = resolve<App>("App");

Metadata persists through the prototype chain per the TC39 spec, so subclasses inherit the metadata entries of their parent classes. Note: NestJS currently uses reflect-metadata for this purpose; this Symbol.metadata behavior is not yet equivalent to NestJS's runtime resolution mechanism.

Practical Use Case: React Metadata for Component Registration

In a React and Node.js SSR setup, class-based components or wrapper classes could use decorator metadata to power a component registry or plugin system, where metadata tags drive server-side route matching or feature flag resolution. A significant limitation applies: TC39 decorators target class declarations, not function components. Since the React ecosystem has largely migrated to function components and hooks, decorator metadata applies to server-side classes, class-based service architectures, or class components still in active use — not to the function-component majority.

Migration from experimentalDecorators and emitDecoratorMetadata

Moving to stable decorators involves several concrete changes. Decorators now store metadata on Symbol.metadata rather than via Reflect.getMetadata, which changes how consumers access it. In tsconfig.json, remove both experimentalDecorators and emitDecoratorMetadata.

Critical: Do not remove the reflect-metadata polyfill until all decorator-consuming frameworks explicitly support TC39 stable decorators. NestJS (as of v10/v11) and TypeORM (as of v0.3.x) still require reflect-metadata and experimentalDecorators. Check each library's release notes for explicit Symbol.metadata / TC39 decorator support before removing the polyfill — premature removal will produce runtime failures in DI resolution and entity metadata loading.

Putting It All Together: Implementation Checklist

Complete TypeScript 5.9 Upgrade Checklist

  1. ☐ Install TypeScript 5.9 beta: npm install typescript@beta — confirm installed version is 5.9.x via npx tsc --version before proceeding
  2. ☐ Read the official release notes and changelog
  3. ☐ Confirm strictInference is a recognized compiler option (run npx tsc --noEmit and check for TS5023 errors)
  4. ☐ If confirmed, enable strictInference in tsconfig.json with noEmit in CI
  5. ☐ Audit and triage strictInference errors
  6. ☐ Fix high-severity inference issues (generics, callbacks, returns)
  7. ☐ Enable strictInference in development builds
  8. ☐ Evaluate decorator metadata migration timeline
  9. ☐ Remove reflect-metadata polyfill only after confirming all consuming frameworks support TC39 stable decorators
  10. ☐ Replace experimentalDecorators / emitDecoratorMetadata with stable equivalents
  11. ☐ Update DI and ORM libraries to compatible versions
  12. ☐ Run full test suite and integration tests
  13. ☐ Update project documentation and onboarding guides

Prerequisites

Install TypeScript 5.9 beta via npm install typescript@beta and confirm 5.9.x with npx tsc --version. Verify Symbol.metadata support by running node -e "console.log(typeof Symbol.metadata)" — it must output "symbol". Set your tsconfig target to ES2022 or later for TC39 decorator syntax, and include "ESNext" in lib for decorator metadata typings. When using "module": "Node16", set moduleResolution to "Node16" as well — this requires explicit .js extensions on all relative imports. Ensure experimentalDecorators is absent or set to false for the TC39 decorator path.

What's Still Missing and What to Watch

Will strictInference Join the strict Family?

The TypeScript team has not committed to any timeline. strictNullChecks was opt-in for several releases before joining the strict family, and strictInference could follow the same path — or not. No GitHub issue or roadmap entry confirms inclusion.

Decorator Metadata and the Broader TC39 Decorators Landscape

Several companion proposals remain in progress at TC39, including decorator constraints and parameter decorators. The absence of stable parameter decorators hits frameworks like NestJS hardest, since they rely on parameter-level metadata for route handlers and injection points. Until parameter decorators advance through TC39 and TypeScript implements them, frameworks will maintain backward-compatible shims or continue supporting the legacy experimental mode in parallel.

Key Takeaways

The strictInference flag is the first opt-in flag since strictNullChecks that catches excess-property leaks in generics, callback return widening, and union narrowing failures that strict: true currently allows. Enabling it in CI first gives you a low-risk path to finding bugs that have silently shipped to production. Stable decorator metadata eliminates the reflect-metadata polyfill for new code written against the TC39 API and removes the need for the experimentalDecorators flag — though framework compatibility (particularly NestJS and TypeORM) remains a gate for migration. Teams that enable both features early will catch more type errors before production and can drop the reflect-metadata dependency in projects that do not depend on frameworks still requiring it. Verify all claims against the official TypeScript 5.9 release notes before adopting in production, and use the checklist above to upgrade methodically.

"Stable decorator metadata eliminates the reflect-metadata polyfill for new code written against the TC39 API and removes the need for the experimentalDecorators flag — though framework compatibility (particularly NestJS and TypeORM) remains a gate for migration."

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.