Mert Özen Further With Every Line
Frontend TypeScript Intermediate — #13

TypeScript Enums, Namespaces and Parameter Properties: Syntax That Isn't Erasable

Mert Özen Nov 3, 2026 8 min 2 views
TypeScript Enums, Namespaces and Parameter Properties: Syntax That Isn't Erasable

We examine numeric and string enums, const enum, namespaces and parameter properties through their compiled output, and see why they aren't erasable, erasableSyntaxOnly, and the as const alternative.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

We define a simple enum with four directions and print its values to the console:

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

console.log(Object.values(Direction));

How many elements does the array printed to the console have, and what's inside it? If you're thinking four directions, four values, take another look. A hint: almost every type you write in TypeScript is erased at compile time, but this code is different.

What Does Erasable Mean?

Since the start of the series we've written our examples by a single rule: if you erase the types, valid JavaScript should be left behind. We call this erasable syntax. That's exactly what Node 26 does when you type node file.ts: it replaces the TypeScript syntax in the file with whitespace and runs what remains. It doesn't type check, and it doesn't generate new code.

A few older TypeScript features don't follow this rule. Enums, namespaces containing runtime code, parameter properties and import = declarations like import x = require(...) are syntax with no counterpart in JavaScript, syntax the compiler translates by generating new code. When Node runs into them it refuses to run the file and reports an ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX error. The --experimental-transform-types flag that used to transform them was removed in Node 26. So to run the examples in this post in Node, you first need to compile them to JavaScript with tsc; in the Playground you can try them directly. Decorators belong to this group too, and we'll cover them separately in the advanced series.

You need to know this syntax, because it's very common in existing codebases and libraries. But we'll also see why new code usually doesn't choose it.

Enums: Numeric and String

In a numeric enum, if you don't assign values, members are numbered starting from 0:

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

function fromNumber(n: number): Direction {
  return n;
}

console.log(Direction.Down); // 1
console.log(Direction[1]); // Down
console.log(fromNumber(99)); // 99

// Error: Type '42' is not assignable to type 'Direction'.
const invalid: Direction = 42;

Direction is both a type and a value. tsc turns it into this JavaScript:

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 = {}));

Each line does two jobs: Direction["Up"] = 0 adds a key from name to number, and the outer assignment adds one from number to name. This is called reverse mapping, and it's why Direction[1] returns "Down". It's also the answer to the opening question: the object has eight keys, and Object.values returns [ 'Up', 'Down', 'Left', 'Right', 0, 1, 2, 3 ].

Notice two more lines in the example. Since TS 5.0, assigning a number literal with no matching member to an enum is an error: 42 was rejected. But a value of type number can still be assigned to a numeric enum; fromNumber(99) returned 99 without a single warning. The type safety of numeric enums is looser than you might think.

In a string enum you give every member a value yourself, and no reverse mapping is generated:

enum Status {
  Active = "ACTIVE",
  Banned = "BANNED",
}

function canLogIn(status: Status): boolean {
  return status === Status.Active;
}

console.log(canLogIn(Status.Active)); // true

// Error: Argument of type '"ACTIVE"' is not assignable to parameter of type 'Status'.
canLogIn("ACTIVE");

The last line may surprise you: the value of Status.Active is exactly "ACTIVE", yet the string literal isn't accepted. String enums step outside TypeScript's structural typing rule and behave almost nominally; you can only pass the value through the enum itself. Sometimes that strictness is what you want, but it also means converting an "ACTIVE" string that comes from an API every single time.

const enum: The Enum That Generates No Code

A const enum never generates the enum object; it inlines the value itself everywhere you use it:

const enum Level {
  Low = 1,
  High = 2,
}

console.log(Level.High);

tsc's output is a single line: console.log(2 /* Level.High */);. Sounds ideal, but for this the compiler has to see the definition of Level. If Level lives in another file, tools that transform files one at a time (Node, most bundlers) can't know the value. TypeScript knows this too, so with isolatedModules or verbatimModuleSyntax on, it doesn't inline an exported const enum and compiles it like a regular enum. In a modern tsconfig, then, the one advantage of const enum largely disappears as well.

Namespaces

A namespace is a grouping tool left over from the days before ES modules:

namespace Geometry {
  export const PI = 3.14;

  export function circleArea(radius: number): number {
    return PI * radius * radius;
  }
}

console.log(Geometry.circleArea(2)); // 12.56

The compiled version is, again, a function and an object:

var Geometry;
(function (Geometry) {
  Geometry.PI = 3.14;
  function circleArea(radius) {
    return Geometry.PI * radius * radius;
  }
  Geometry.circleArea = circleArea;
})(Geometry || (Geometry = {}));

Today a module does the same job: you export PI and circleArea from a geometry.ts file, and the consumer writes import * as Geometry from "./geometry.ts". There is one exception: a namespace that contains only types leaves nothing behind at runtime and counts as erasable. The real home of namespaces is declaration files; we'll meet them there again in the next post. One more note: the old module Geometry { } spelling that does the same thing was deprecated in TS 6.0 and is an error in 7.0; the keyword is namespace.

Parameter Properties

Parameter properties, which we touched on briefly in the classes post, put an access modifier on a constructor parameter to declare and assign a property in one go:

class Account {
  constructor(
    public readonly id: number,
    private owner: string,
  ) {}
}

It looks short, but the compiler generates code you didn't write:

class Account {
  id;
  owner;
  constructor(id, owner) {
    this.id = id;
    this.owner = owner;
  }
}

The erasable equivalent is to write the same thing explicitly:

class Account {
  readonly id: number;
  private owner: string;

  constructor(id: number, owner: string) {
    this.id = id;
    this.owner = owner;
  }
}

Here readonly and private are just type information; when they're erased, a valid class remains. The problem isn't these modifiers, it's putting them on a constructor parameter, where they generate code.

erasableSyntaxOnly

The erasableSyntaxOnly flag, which arrived with TS 5.8, catches all of this syntax during type checking. That's why it's on in the recommended tsconfig for Node:

// Error: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
enum Direction {
  Up,
  Down,
}

// Error: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
namespace Geometry {
  export const PI = 3.14;
}

class Account {
  // Error: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
  constructor(public readonly id: number) {}
}

// Fine: a namespace containing only types can be erased
namespace Shapes {
  export interface Point {
    x: number;
    y: number;
  }
}

All three errors come with the same code: TS1294. const enum and import = get the same message as well. That way you see the error Node would report at runtime while you're still in the editor.

The Alternative: an as const Object and a Union

The two jobs an enum does, naming a set of values and using that set as a type, can be done entirely with erasable tools. Combine as const from the beginner series with keyof typeof from this series:

const Status = {
  Active: "ACTIVE",
  Banned: "BANNED",
} as const;

type Status = (typeof Status)[keyof typeof Status];

function describe(status: Status): string {
  switch (status) {
    case Status.Active:
      return "can log in";
    case "BANNED":
      return "blocked";
  }
}

console.log(describe("ACTIVE")); // can log in
console.log(describe(Status.Banned)); // blocked
console.log(Object.values(Status)); // [ 'ACTIVE', 'BANNED' ]

The name Status means two separate things here: a value (the object) and a type (the "ACTIVE" | "BANNED" union). Because values and types live in separate namespaces in TypeScript, this isn't a clash but a deliberate pairing. What you gain: you can work by name with Status.Active or directly by value with "ACTIVE"; Object.values returns only the real values; and the compiled code is exactly the object you wrote. If you don't need to name the values in one place, a plain union (type Direction = "up" | "down" | "left" | "right") is simpler still.

Common Pitfalls

Iterating over a numeric enum with Object.keys or Object.values. Because of reverse mapping you see every member twice, as in the opening example.

Trusting the type safety of numeric enums too much. Number literals are checked, but any value of type number is accepted. Validate a number coming from outside yourself before assigning it to an enum.

Trying to pass a string literal to a string enum. Values from an API or a form are plain strings; turning them into the enum takes separate mapping code.

Assuming a const enum from another file will always be inlined. With tools that work file by file, and with isolatedModules on, there's no such guarantee.

Running a file with an enum through node file.ts and only seeing the error at runtime. Adding erasableSyntaxOnly to tsconfig moves that surprise to compile time.

A Small Experiment

★☆☆ Write the Direction enum in the Playground and inspect the output in the ".JS" tab on the right. Then change enum to const enum and see how the output of a console.log(Direction.Left) line changes.

★★☆ Predict, then try: for enum Color { Red = "RED", Green = "GREEN" }, what is Object.keys(Color).length? Explain the difference from a numeric enum in terms of reverse mapping.

★★☆ Does it compile? With erasableSyntaxOnly on: class Point { constructor(private x: number) {} } and class Point { private x: number; constructor(x: number) { this.x = x; } }. Which one passes, and why?

★★★ Rewrite the enum below with an as const object and a union, so that a function label(role: Role): string function can be called the same way in both versions: enum Role { Admin = "admin", Editor = "editor", Viewer = "viewer" }. Which way of calling it compiles only in the new version?

In this post we looked at syntax that leaves traces behind when types are erased. In the next post we look at the exact opposite: files that contain no runtime code at all, only types. Declaration files, the .d.ts files, will show how we introduce a library written in JavaScript to TypeScript and what the declare keyword is for; we'll also meet the types-only namespace there again.