Showing posts with label TypeScript. Show all posts
Showing posts with label TypeScript. Show all posts

Thursday, 25 September 2025

TypeScript namespaces: when the docs say one thing and ESLint says another

G'day:

This is one of those "the documentation says one thing, the tooling says another, what the hell am I actually supposed to do?" situations that seems to crop up constantly in modern JavaScript tooling.

I was working through TypeScript enums as part of my learning project, and I wanted to add methods to an enum - you know, the kind of thing you can do with PHP 8 enums where you can have both the enum values and associated behavior in the same construct. Seemed like a reasonable thing to want to do.

TypeScript enums don't support methods directly, but some digging around Stack Overflow led me to namespace merging as a solution. Fair enough - except as soon as I implemented it, ESLint started having a proper whinge about using namespaces at all.

Cue an hour of trying to figure out whether I was doing something fundamentally wrong, or whether the tooling ecosystem just hasn't caught up with legitimate use cases. Turns out it's a bit of both.

The contradiction

Here's what the official TypeScript documentation says about namespaces:

A note about terminology: It's important to note that in TypeScript 1.5, the nomenclature has changed. "Internal modules" are now "namespaces". "External modules" are now simply "modules", as to align with ECMAScript 2015's terminology, (namely that module X { is equivalent to the now-preferred namespace X {).

Note that "now-preferred" bit. Sounds encouraging, right?

And here's what the ESLint TypeScript rules say:

TypeScript historically allowed a form of code organization called "custom modules" (module Example {}), later renamed to "namespaces" (namespace Example). Namespaces are an outdated way to organize TypeScript code. ES2015 module syntax is now preferred (import/export).

So which is it? Are namespaces preferred, or are they outdated?

The answer, as usual with JavaScript tooling, is "it depends, and the documentation is misleading".

The TypeScript docs were written when they renamed the syntax from module to namespace - the "now-preferred" referred to using the namespace keyword instead of the old module keyword. It wasn't saying namespaces were preferred over ES modules; it was just clarifying the syntax change within the namespace feature itself.

The ESLint docs reflect current best practices: ES2015 modules (import/export) are indeed the standard way to organize code now. Namespaces are generally legacy for most use cases.

But "most use cases" isn't "all use cases". And this is where things get interesting.

The legitimate use case: enum methods

What I wanted to do was add a method to a TypeScript enum, similar to what you can do in PHP:

// What I wanted (conceptually)
enum MaoriNumber {
  Tahi = 'one',
  Rua = 'two',
  Toru = 'three',
  Wha = 'four',
  
  // This doesn't work in TypeScript
  static fromValue(value: string): MaoriNumber {
    // ...
  }
}

The namespace merging approach lets you achieve this by declaring an enum and then a namespace with the same name:

// src/lt-15/namespaces.ts

export enum MaoriNumber {
  Tahi = 'one',
  Rua = 'two',
  Toru = 'three',
  Wha = 'four',
}

// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace MaoriNumber {
  const enumKeysOnly = Object.keys(MaoriNumber).filter(
    (key) =>
      typeof MaoriNumber[key as keyof typeof MaoriNumber] !== 'function'
  )

  export function fromValue(value: string): MaoriNumber {
    const valueAsMaoriNumber: MaoriNumber = value as MaoriNumber
    const index = Object.values(MaoriNumber).indexOf(valueAsMaoriNumber);
    if (index === -1) {
      throw new Error(`Value "${value}" is not a valid MaoriNumber`);
    }
    const elementName: string = enumKeysOnly[index];
    const typedElementName = elementName as keyof typeof MaoriNumber;

    return MaoriNumber[typedElementName] as MaoriNumber;
  }
}

This gives you exactly what you want: MaoriNumber.Tahi for enum access and MaoriNumber.fromValue() for the method, all properly typed.

The // eslint-disable-next-line comment acknowledges that yes, I know namespaces are generally discouraged, but this is a specific case where they're the right tool for the job.

Why the complexity in fromValue?

You might wonder why that fromValue function is doing so much filtering and type casting. It's because of the namespace merging itself.

When you merge an enum with a namespace, TypeScript sees MaoriNumber as containing both the enum values and the functions. So Object.keys(MaoriNumber) returns:

['Tahi', 'Rua', 'Toru', 'Wha', 'fromValue']

And keyof typeof MaoriNumber becomes:

"Tahi" | "Rua" | "Toru" | "Wha" | "fromValue"

The filtering step removes the function keys so we only work with the actual enum values. The type assertions handle the fact that TypeScript can't statically analyze that our runtime filtering has eliminated the function possibility.

Sidebar: that keyof typeof bit took a while for me to work out. Well I say "work out": I just read this Q&A on Stack Overflow: What does "keyof typeof" mean in TypeScript?. I didn't find anything useful in the actual docs. I look at it more closely in some other code I wrote today… there might be an article in that too. We'll see (I'll cross-ref it here if I write it).

Testing the approach

The tests prove that both aspects work correctly:

// tests/lt-15/namespaces.test.ts

describe('Emulating enum with method', () => {
  it('has accessible enums', () => {
    expect(MaoriNumber.Tahi).toBe('one')
  })
  
  it('has accessible methods', () => {
    expect(MaoriNumber.fromValue('two')).toEqual(MaoriNumber.Rua)
  })
  
  it("won't fetch the method as an 'enum' entry", () => {
    expect(() => {
      MaoriNumber.fromValue('fromValue')
    }).toThrowError('Value "fromValue" is not a valid MaoriNumber')
  })
  
  it("will error if the string doesn't match a MaoriNumber", () => {
    expect(() => {
      MaoriNumber.fromValue('rima')
    }).toThrowError('Value "rima" is not a valid MaoriNumber')
  })
})

The edge case testing is important here - we want to make sure the function doesn't accidentally treat its own name as a valid enum value, and that it properly handles invalid inputs.

Alternative approaches

You could achieve similar functionality with a class and static methods:

const MaoriNumberValues = {
  Tahi: 'one',
  Rua: 'two', 
  Toru: 'three',
  Wha: 'four'
} as const

type MaoriNumber = typeof MaoriNumberValues[keyof typeof MaoriNumberValues]

class MaoriNumbers {
  static readonly Tahi = MaoriNumberValues.Tahi
  static readonly Rua = MaoriNumberValues.Rua
  static readonly Toru = MaoriNumberValues.Toru
  static readonly Wha = MaoriNumberValues.Wha
  
  static fromValue(value: string): MaoriNumber {
    // implementation
  }
}

But this is more verbose, loses some of the enum benefits (like easy iteration), and doesn't give you the same clean MaoriNumber.Tahi syntax you get with the namespace approach.

So when should you use namespaces?

Based on this experience, I'd say namespace merging with enums is one of the few remaining legitimate use cases for TypeScript namespaces. The modern alternatives don't provide the same ergonomics for this specific pattern.

For everything else - code organisation, avoiding global pollution, grouping related functionality - ES modules are indeed the way forward. But when you need to add methods to enums and you want clean, intuitive syntax, namespace merging is still the right tool.

The key is being intentional about it. Use the ESLint disable comment to acknowledge that you're making a conscious choice, not just ignoring best practices out of laziness.

It's one of those situations where the general advice ("don't use namespaces") doesn't account for specific edge cases where they're still the best solution available. The tooling will complain, but sometimes the tooling is wrong.

I'll probably circle back to write up more about TypeScript enums in general - there's a fair bit more to explore there. But for now, I've got a working solution for enum methods that gives me the PHP-like behavior I was after, even if it did require wading through some contradictory documentation to get there.

Credit where it's due: Claudia (claude.ai) was instrumental in both working through the namespace merging approach and helping me understand the TypeScript type system quirks that made the implementation more complex than expected. The back-and-forth debugging of why MaoriNumber[typedElementName] was causing type errors was particularly useful - sometimes you need another perspective to spot what the compiler is actually complaining about. She also helped draft this article, which saved me a few hours of writing time. GitHub Copilot's code review feature has been surprisingly helpful too - it caught some genuine issues with error handling and performance that I'd missed during the initial implementation.

Righto.

--
Adam