跳到正文
DEV Community· jsmanifest·· 1 小時前AI 評分48

TypeScript const enum 已棄用:應遷移至甚麼,以及編譯器團隊作此決定的原因

TypeScript `const` Enums Are Deprecated: What to Migrate to and Why the Compiler Team Made the Call

AI 導讀

TypeScript 編譯器團隊已將 const enum 列為棄用,因其依賴跨模組內聯,在隔離模組編譯及依賴版本漂移時可能引發執行階段錯誤和資料損壞。以 `as const` 配合聯合類型可保留類型安全,並避免執行階段物件依賴。發布 `.d.ts` 檔案的程式庫應完全移除 const enum;應用程式則可由高風險模組開始逐步遷移。

正文

TypeScript const Enums Are Deprecated: What to Migrate to and Why the Compiler Team Made the Call

This article was written with the assistance of AI, under human supervision and review.

TypeScript const enums promised zero-runtime cost for constant values, but they broke isolated module compilation and caused runtime errors downstream. The compiler team deprecated them not because of theoretical purity, but because they caused production failures when developers mixed const enums with modern build tools like esbuild, swc, and Vite. A const enum in a library's .d.ts file becomes a landmine: the consuming project inlines values at compile time but ships code that expects those enum objects to exist at runtime. When versions drift between compile and runtime dependencies, conditionals take the wrong branch and silent data corruption follows.

The problem diagram shows the failure path:

const enum compilation breaks when compiler cannot see declaration file

const enum compilation breaks when compiler cannot see declaration file

The solution eliminates compile-time inlining and removes the dependency on cross-module information:

union types with as const provide type safety without runtime dependencies

union types with as const provide type safety without runtime dependencies

TypeScript const enum deprecation warning in compiler output

TypeScript const enum deprecation warning in compiler output

The migration path depends on whether the enum appears in a library or application code. Libraries must remove const enums entirely because they cannot control how downstream consumers compile their code. Application code can migrate incrementally, starting with high-risk modules that interact with external dependencies.

Key Takeaways

  • Const enums fail in isolated module compilation because the compiler cannot access cross-file information to inline values, causing unresolved imports at runtime.
  • The TypeScript team deprecated const enums to prevent version drift bugs where compile-time values differ from runtime values, leading to wrong conditional branches and data corruption.
  • Union types with as const provide equivalent type safety without runtime objects, making them compatible with modern build tools that compile modules in isolation.
  • Libraries shipping .d.ts files with const enums force downstream consumers to disable isolatedModules, breaking compatibility with esbuild, swc, and other fast compilers.
  • The preserveConstEnums compiler option emits runtime objects but defeats the original zero-cost promise and creates inconsistent behavior between development and production builds.

The Technical Problems With Const Enums

Const enums operate by replacing references with literal values at compile time. When the compiler encounters Direction.Up, it substitutes the numeric literal 0 directly into the emitted JavaScript. This inlining removes the enum object from the output entirely, which sounds efficient until modules compile independently.

The TypeScript documentation on enums explains that const enums "are completely removed during compilation" and "members are inlined at use sites." The removal happens because the compiler treats the enum as a macro expansion rather than a runtime value.

const enum inlining requires cross-module visibility to substitute values

const enum inlining requires cross-module visibility to substitute values

The inlining breaks when the compiler processes files in isolation. Modern build tools compile TypeScript modules independently to maximize parallelism. A module that imports a const enum from another file cannot inline the values because it never reads the declaring file. The import statement stays in the output JavaScript, referencing an enum object that was never emitted.

The failure mode appears when libraries publish ambient const enums in declaration files. A library's .d.ts file declares the const enum shape but provides no implementation. The consuming project's compiler sees the declaration and inlines values based on that snapshot. At runtime, the library's JavaScript code contains no enum object because the const modifier stripped it during the library's build. The consuming code executes with hardcoded values that may not match the library's current version.

Version drift amplifies the problem. A project compiles against version 1.2 of a library, inlining Status.Active = 5. The deployment runtime loads version 1.3 where Status.Active changed to 7. The compiled code still checks if (status === 5) but the library now returns 7 for active status. The conditional silently fails and the application continues executing on the wrong path.

The isolatedModules compiler option exists specifically to catch these situations. When enabled, TypeScript rejects code patterns that only work with whole-program compilation. The documentation states that isolatedModules "is fundamentally incompatible with ambient const enums" because inlining requires seeing the enum definition.

Build tools choose isolated module compilation for speed. The TypeScript 5.0 release notes introduced the verbatimModuleSyntax option to make module semantics explicit and predictable across different compilers. The new option removes import elision and treats all module syntax literally, which makes const enum inlining impossible.

The TypeScript team deprecated const enums because they require whole-program knowledge in an ecosystem moving toward isolated compilation. The tradeoff between zero-cost abstractions and compilation speed tilted decisively toward speed when developers started hitting production failures from version drift.

How Const Enums Break in Isolated Module Compilation

The breakage becomes concrete when examining actual compiler output. A const enum in one file and an import in another produces different JavaScript depending on whether the compiler processes them together or separately.

// constants.ts
export const enum Direction {
  Up = 0,
  Down = 1,
  Left = 2,
  Right = 3,
}

// movement.ts
import { Direction } from "./constants";

export function move(direction: Direction): string {
  switch (direction) {
    case Direction.Up:
      return "moving up";
    case Direction.Down:
      return "moving down";
    case Direction.Left:
      return "moving left";
    case Direction.Right:
      return "moving right";
  }
}

When TypeScript compiles both files together, the output for movement.ts replaces enum references with literals:

// movement.js (whole-program compilation)
export function move(direction) {
  switch (direction) {
    case 0: // Direction.Up
      return "moving up";
    case 1: // Direction.Down
      return "moving down";
    case 2: // Direction.Left
      return "moving left";
    case 3: // Direction.Right
      return "moving right";
  }
}

The constants.ts file emits nothing because the const enum disappears entirely. The JavaScript output contains no Direction object and no export statement. This works only because the compiler inlined the values before generating code.

When a build tool compiles movement.ts in isolation, it cannot inline the values because it never reads constants.ts. The output keeps the import statement:

// movement.js (isolated module compilation)
import { Direction } from "./constants";

export function move(direction) {
  switch (direction) {
    case Direction.Up:
      return "moving up";
    case Direction.Down:
      return "moving down";
    case Direction.Left:
      return "moving left";
    case Direction.Right:
      return "moving right";
  }
}

At runtime, the JavaScript engine tries to import Direction from constants.js. That file exports nothing because the const enum was stripped during the library's compilation. The import fails with an unresolved module specifier or imports an undefined value, causing the switch statement to match no cases.

Example of runtime error from const enum in isolated compilation

Example of runtime error from const enum in isolated compilation

The isolatedModules documentation calls out this specific failure. The compiler option enables a check that rejects const enum imports from external modules. The error message reads: "Cannot access ambient const enums when the '--isolatedModules' flag is provided."

The ambient qualifier matters. An ambient const enum appears in a declaration file without an implementation. Libraries ship .d.ts files containing const enum declarations, expecting consumers to inline the values. The consumer's compiler sees only the declaration and has no way to verify the values match the library's runtime code.

The fix requires either removing the const modifier to emit a runtime object, or migrating to an alternative that works without cross-file inlining. The next sections cover both migration paths and when to use each one.

Migration Path 1: Union Types With as const

Union types combined with the as const assertion replicate const enum type safety without emitting runtime code. The pattern defines an object with as const to create literal types, then extracts a union type from the object's values.

// Before: const enum
const enum Direction {
  Up = 0,
  Down = 1,
  Left = 2,
  Right = 3,
}

// After: union type with as const
const Direction = {
  Up: 0,
  Down: 1,
  Left: 2,
  Right: 3,
} as const;

type Direction = typeof Direction[keyof typeof Direction];

The as const assertion tells the compiler to infer the most specific type possible. Without it, TypeScript widens the type to number. With as const, each property gets its exact literal type: 0, 1, 2, 3. The type extraction typeof Direction[keyof typeof Direction] produces the union 0 | 1 | 2 | 3.

union type extraction flow from const object to type parameter

union type extraction flow from const object to type parameter

Functions that previously accepted the enum type now accept the extracted union type. The type safety remains identical:

function move(direction: Direction): string {
  switch (direction) {
    case Direction.Up:
      return "moving up";
    case Direction.Down:
      return "moving down";
    case Direction.Left:
      return "moving left";
    case Direction.Right:
      return "moving right";
  }
}

move(Direction.Up); // type-safe
move(Direction.Down); // type-safe
move(5); // error: Argument of type '5' is not assignable to parameter of type '0 | 1 | 2 | 3'

The TypeScript compiler enforces that callers pass only the valid direction values. The compiled JavaScript contains a runtime object with the direction properties, making it compatible with isolated module compilation:

const Direction = {
  Up: 0,
  Down: 1,
  Left: 2,
  Right: 3,
};

function move(direction) {
  switch (direction) {
    case Direction.Up:
      return "moving up";
    case Direction.Down:
      return "moving down";
    case Direction.Left:
      return "moving left";
    case Direction.Right:
      return "moving right";
  }
}

The object exists at runtime, so imports resolve correctly even when modules compile in isolation. The tradeoff is a small runtime cost: the object occupies memory and requires a property access instead of using an immediate literal value. For most applications, the cost disappears in the noise of other object allocations.

String-valued enums migrate the same way:

// Before: string const enum
const enum LogLevel {
  Debug = "DEBUG",
  Info = "INFO",
  Warn = "WARN",
  Error = "ERROR",
}

// After: union type with as const
const LogLevel = {
  Debug: "DEBUG",
  Info: "INFO",
  Warn: "WARN",
  Error: "ERROR",
} as const;

type LogLevel = typeof LogLevel[keyof typeof LogLevel];

The union type becomes "DEBUG" | "INFO" | "WARN" | "ERROR", which the compiler treats as distinct literal types. The TypeScript handbook on enums notes that "an object with as const could suffice" for many enum use cases, and this migration proves the point.

The pattern works in libraries because it requires no special compiler behavior. A library exports the const object and the type, and consumers import both normally. No inlining happens and no cross-file dependencies exist. The module system handles the runtime object like any other export.

Migration Path 2: Plain const Objects

When type safety matters less than convenience, a plain const object without type extraction provides a simpler migration. The object still gets as const to freeze its type, but no union type extraction happens.

// Before: const enum
const enum HttpStatus {
  OK = 200,
  Created = 201,
  BadRequest = 400,
  Unauthorized = 401,
  NotFound = 404,
}

// After: plain const object
const HttpStatus = {
  OK: 200,
  Created: 201,
  BadRequest: 400,
  Unauthorized: 401,
  NotFound: 404,
} as const;

Functions accept number instead of the specific union type:

function handleResponse(status: number): void {
  if (status === HttpStatus.OK) {
    console.log("Success");
  } else if (status === HttpStatus.NotFound) {
    console.log("Resource not found");
  }
}

The compiler allows any number, not just the defined status codes. This loses type safety but matches the original const enum behavior more closely. Const enums with numeric values did not prevent passing arbitrary numbers either, they just provided autocomplete for the defined constants.

plain const object migration removes type extraction step

plain const object migration removes type extraction step

The JavaScript output includes the object exactly as written:

const HttpStatus = {
  OK: 200,
  Created: 201,
  BadRequest: 400,
  Unauthorized: 401,
  NotFound: 404,
};

function handleResponse(status) {
  if (status === HttpStatus.OK) {
    console.log("Success");
  } else if (status === HttpStatus.NotFound) {
    console.log("Resource not found");
  }
}

This pattern suits internal application code where the team controls all usage sites. The lack of type constraints speeds up refactoring because changing a value requires updating only the object definition, not every function signature that references it.

Libraries should prefer the union type approach. Exporting a plain object without extracted types gives consumers no guidance about valid values. The autocomplete shows the object properties but the type system accepts anything, leading to runtime errors when callers pass invalid values.

The const object approach also handles computed enum values better than union types. Const enums with expressions become cumbersome to migrate to unions:

// Before: const enum with computed values
const enum Permissions {
  None = 0,
  Read = 1 << 0,
  Write = 1 << 1,
  Execute = 1 << 2,
  ReadWrite = Read | Write,
}

// After: plain const object preserves expressions
const Permissions = {
  None: 0,
  Read: 1 << 0,
  Write: 1 << 1,
  Execute: 1 << 2,
  ReadWrite: (1 << 0) | (1 << 1),
} as const;

Extracting a union type here requires computing the expression results manually (1 | 2 | 4 | 3), which defeats the purpose of the named constants. The plain object keeps the expressions readable and maintains the relationship between combined flags.

Const Enums vs Regular Enums vs Modern Alternatives

The three approaches differ in runtime presence, type safety, and compatibility with modern build tools. The table summarizes the key distinctions:

Feature Const Enum Regular Enum Union Type with as const Plain const Object
Runtime object No Yes Yes Yes
Type safety Compile-time only Full Full None
Isolated modules Breaks Works Works Works
Bundle size impact None Small object Small object Small object
Reverse mapping No Yes No No
Library-safe No Yes Yes Conditional

comparison flowchart showing const enum versus modern alternatives

comparison flowchart showing const enum versus modern alternatives

Regular enums emit a runtime object that supports reverse mapping. Numeric enums create a two-way mapping between names and values:

enum Direction {
  Up,
  Down,
  Left,
  Right,
}

console.log(Direction.Up); // 0
console.log(Direction[0]); // "Up"

The compiled JavaScript contains an immediately invoked function expression that builds the bidirectional object:

var Direction;
(function (Direction) {
  Direction[Direction["Up"] = 0] = "Up";
  Direction[Direction["Down"] = 1] = "Down";
  Direction[Direction["Left"] = 2] = "Left";
  Direction[Direction["Right"] = 3] = "Right";
})(Direction || (Direction = {}));

This flexibility comes with a larger bundle footprint. The generated code uses more bytes than a simple object literal. Most applications never use reverse mapping, making the extra code pure overhead.

Union types with as const provide the same compile-time type safety as enums without the reverse mapping machinery. The object literal approach generates minimal JavaScript and works identically across all compilers. The TypeScript 5.0 release notes mention that the verbatimModuleSyntax option makes module semantics predictable, and union types fit that predictability better than enums with their special compilation rules.

Plain const objects sacrifice type safety for simplicity. They work when the codebase treats the values as documentation rather than constraints. API response status codes often fall into this category because external services return arbitrary numbers anyway.

The migration path depends on the enum's role in the codebase. Enums used as discriminated union tags need full type safety and should become union types. Enums used as named constants can become plain objects. Enums with reverse mapping lookups should stay as regular enums.

What to Do With Existing Const Enum Code

Migrating a codebase with existing const enums requires assessing impact before changing code. The first step identifies where const enums appear and whether they cross module boundaries.

migration decision flowchart for const enum replacement strategy

migration decision flowchart for const enum replacement strategy

Search the codebase for const enum declarations and catalog their locations. Distinguish between application-internal enums and enums exported from library packages. Internal enums can migrate gradually. Exported enums require immediate attention because downstream consumers may already be hitting compilation failures.

For libraries, the migration path is strict. Remove all const enums from published declaration files. The TypeScript documentation on const enums explains that const enums in .d.ts files create "subtle implications" and "pitfalls" for consumers. The practical implication is that consumers cannot use isolatedModules if they import your const enums.

The migration steps for a library:

  1. Replace const enum declarations with union types using as const
  2. Export both the const object and the extracted type
  3. Update internal code to use the new types
  4. Bump the major version because the change breaks consumers who directly accessed enum values
  5. Document the migration in the changelog

Application code has more flexibility. The preserveConstEnums compiler option provides a temporary bridge. When enabled, the compiler emits runtime objects for const enums instead of removing them entirely. This makes the code compatible with isolated module compilation while preserving the const enum syntax:

// tsconfig.json
{
  "compilerOptions": {
    "preserveConstEnums": true
  }
}

The output JavaScript now includes the enum object:

const Direction = {
  Up: 0,
  Down: 1,
  Left: 2,
  Right: 3,
};

The const enum keyword becomes equivalent to a regular enum in terms of output, but the TypeScript compiler still inlines references during type checking. This creates an inconsistency where development builds inline values but production builds use the runtime object.

The better approach for application code migrates const enums to union types one module at a time. Start with modules that interact with external dependencies or appear in the critical path. These modules cause the most visible failures when const enums break.

The migration preserves type safety by using the union type extraction pattern. Tests catch any mismatches where code passed invalid values that const enums silently accepted. Strict TypeScript configurations with strict enabled surface these issues immediately.

For large codebases, automated refactoring tools can handle the mechanical transformation. A codemod script searches for const enum declarations, generates the equivalent as const object and type extraction, and updates references throughout the codebase. The TypeScript compiler API provides the parsing and type information needed to build such tools.

The investment in migration pays off in build performance and reliability. Codebases that remove const enums can enable isolatedModules and use faster build tools. The reduction in compilation time often measures in seconds for medium-sized projects and minutes for large monorepos.

Frequently Asked Questions

Can I still use regular enums without the const modifier?

Yes. Regular enums work correctly with isolated module compilation because they emit runtime objects. The TypeScript compiler team deprecated only const enums due to their specific inlining behavior. Regular enums remain a supported feature with no planned deprecation.

Does preserveConstEnums make const enums safe to use?

No. The preserveConstEnums option emits runtime objects but does not solve the version drift problem. If your library ships .d.ts files with const enums, downstream consumers still inline values at compile time based on your declaration file. The runtime object your library includes may have different values, causing the same silent failures const enums are known for.

Why not just ban const enums with a linter rule?

Linter rules prevent new const enums but do not address existing ones or const enums in dependencies. The TypeScript compiler deprecated const enums to signal that the feature causes real problems and the team will not fix edge cases. Migration to alternatives provides a permanent solution rather than piecemeal prevention.

Will regular enums eventually be deprecated too?

The TypeScript team has not announced plans to deprecate regular enums. The TypeScript handbook still documents enums as a standard feature alongside the as const alternative, giving developers a choice based on their needs. Const enums failed because they broke fundamental module system assumptions, not because enums as a concept are flawed.

How do union types handle bitwise operations like flags?

Union types cannot represent computed values in the type itself, so bitwise flag operations work better with plain const objects. Define the base flags as separate properties and combine them at runtime. The type system sees only the defined properties, not the computed combinations. This limitation makes plain objects more practical than union types for permission flags and similar use cases.

The Future of Enums in TypeScript

The TypeScript compiler team's decision to deprecate const enums reflects a broader shift toward compilation models that prioritize speed and simplicity over optimization tricks. Modern JavaScript bundlers and build tools achieve better performance through parallelization and caching than through aggressive inlining. The zero-runtime-cost promise of const enums mattered more when JavaScript environments were slower and every byte counted.

Union types with as const emerged as the recommended pattern because they align with JavaScript's object literals while providing TypeScript's type safety. The pattern requires no special compiler support and works identically whether the code compiles with tsc, esbuild, swc, or any future compiler. Developers can reason about the code's behavior without understanding const enum inlining rules.

Related posts on TypeScript's evolution cover adjacent deprecation decisions. The TypeScript 6 breaking changes guide discusses other features the compiler team phased out for similar reliability reasons. The TypeScript 6 final JavaScript release explains how new ECMAScript proposals influence TypeScript's feature set. The TypeScript decorators legacy to TC39 migration shows another case where TypeScript deprecated an experimental feature in favor of the standardized alternative.

Const enums served their purpose when build pipelines were simpler and projects rarely mixed compilers. The ecosystem has moved on. The migration to union types or const objects removes a source of subtle bugs and makes TypeScript codebases more portable across different build tools. Teams that complete the migration gain faster build times and fewer production surprises. The const enum deprecation pushes TypeScript toward patterns that scale with modern development workflows rather than fighting against them.

來源:DEV Community · dev.to