Tuesday, 30 September 2025

TypeScript constructor overloading: when one implementation has to handle multiple signatures

G'day:

I've been working through TypeScript classes, and today I hit constructor overloading. Coming from PHP where you can't overload constructors at all (you get one constructor, that's it), the TypeScript approach seemed straightforward enough: declare multiple signatures, implement once, job done.

Turns out the "implement once" bit is where things get interesting.

The basic pattern

TypeScript lets you declare multiple constructor signatures followed by a single implementation:

constructor()
constructor(s: string)
constructor(n: number)
constructor(s: string, n: number)
constructor(p1?: string | number, p2?: number) {
  // implementation handles all four cases
}

The first four lines are just declarations - they tell TypeScript "these are the valid ways to call this constructor". The final signature is the actual implementation that has to handle all of them.

Simple enough when you've got a no-arg constructor and a two-arg constructor - those are clearly different. But what happens when you need two different single-argument constructors, one taking a string and one taking a number?

That's where I got stuck.

The implementation signature problem

Here's what I wanted to support:

const empty = new Numeric()                    // both properties null
const justString = new Numeric('forty-two')    // asString set, asNumeric null
const justNumber = new Numeric(42)             // asNumeric set, asString null
const both = new Numeric('forty-two', 42)      // both properties set

(from constructors.test.ts)

My first attempt at the implementation looked like this:

constructor()
constructor(s: string)
constructor(s: string, n: number)
constructor(s?: string, n?: number) {
  this.asString = s ?? null
  this.asNumeric = n ?? null
}

Works fine for the no-arg, single-string, and two-arg cases. But then I needed to add the single-number constructor:

constructor(n: number)

And suddenly the compiler wasn't happy: "This overload signature is not compatible with its implementation signature."

The error pointed at the new overload, but the actual problem was in the implementation. It took me ages (and asking Claudia) to work this out. This is entirely down to me not reading, but just looking at what line it was pointing too. Duh. The first parameter was typed as string (or undefined), but the new overload promised it could also be a number. The implementation couldn't deliver on what the overload signature was promising.

Why neutral parameter names matter

The fix was to change the implementation signature to accept both types:

constructor(p1?: string | number, p2?: number) {
  // ...
}

But here's where the parameter naming became important. My initial instinct was to keep using meaningful names like s and n:

constructor(s?: string | number, n?: number)

This felt wrong. When you're reading the implementation code and you see a parameter called s, you expect it to be a string. But now it might be a number. The name actively misleads you about what the parameter contains.

Switching to neutral names like p1 and p2 made the implementation logic much clearer - these are just "parameter slots" that could contain different types depending on which overload was called. No assumptions about what they contain.

Runtime type checking

Once the implementation signature accepts both types, you need runtime logic to figure out which overload was actually called:

constructor(p1?: string | number, p2?: number) {
  if (typeof p1 === 'number' && p2 === undefined) {
    this.asNumeric = p1
    return
  }
  this.asString = (p1 as string) ?? null
  this.asNumeric = p2 ?? null
}

(from constructors.ts)

The first check handles the single-number case: if the first parameter is a number and there's no second parameter, we're dealing with new Numeric(42). Set asNumeric and bail out.

Everything else falls through to the default logic: treat the first parameter as a string (or absent) and the second parameter as a number (or absent). This covers the no-arg, single-string, and two-arg cases.

The type assertion (p1 as string) is necessary because TypeScript can't prove that p1 is a string at that point - we've only eliminated the case where it's definitely a number. From the compiler's perspective, it could still be string | number | undefined.

The bug I didn't notice

I had the implementation working and all my tests passing. Job done, right? Except when I submitted the PR, GitHub Copilot's review flagged this:

this.asString = (p1 as string) || null
this.asNumeric = p2 || null
The logic for handling empty strings is incorrect. An empty string ('') will be converted to null due to the || operator, but empty strings should be preserved as valid string values. Use nullish coalescing (??) instead or explicit null checks.

Copilot was absolutely right. The || operator treats all falsy values as "use the right-hand side", which includes:

  • '' (empty string)
  • 0 (zero)
  • false
  • null
  • undefined
  • NaN

So new Numeric('') would set asString to null instead of '', and new Numeric('test', 0) would set asNumeric to null instead of 0. Both are perfectly valid values that the constructor should accept.

The ?? (nullish coalescing) operator only treats null and undefined as "use the right-hand side", which is exactly what I needed:

this.asString = (p1 as string) ?? null
this.asNumeric = p2 ?? null

Now empty strings and zeros are preserved as valid values.

Testing the edge cases

The fact that this bug existed meant my initial tests weren't comprehensive enough. I'd tested the basic cases but missed the edge cases where valid values happen to be falsy.

I added tests for empty strings and zeros:

it('accepts an empty string as the only argument', () => {
  const o: Numeric = new Numeric('')

  expect(o.asString).toEqual('')
  expect(o.asNumeric).toBeNull()
})

it('accepts zero as the only argument', () => {
  const o: Numeric = new Numeric(0)

  expect(o.asNumeric).toEqual(0)
  expect(o.asString).toBeNull()
})

it('accepts an empty string as the first argument', () => {
  const o: Numeric = new Numeric('', -1)

  expect(o.asString).toEqual('')
})

it('accepts zero as the second argument', () => {
  const o: Numeric = new Numeric('NOT_TESTED', 0)

  expect(o.asNumeric).toEqual(0)
})

(from constructors.test.ts)

With the original || implementation, all four of these tests failed. After switching to ??, they all passed. That's how testing is supposed to work - the tests catch the bug, you fix it, the tests confirm the fix.

Fair play to Copilot for spotting this in the PR review. It's easy to miss falsy edge cases when you're focused on getting the type signatures right.

Method overloading in general

Worth noting that constructor overloading is just a specific case of method overloading. Any method can use this same pattern of multiple signatures with one implementation:

class Example {
  doThing(): void
  doThing(s: string): void
  doThing(n: number): void
  doThing(p?: string | number): void {
    // implementation handles all cases
  }
}

The same principles apply: the implementation signature needs to be flexible enough to handle all the declared overloads, and you need runtime type checking to figure out which overload was actually called.

Constructors just happen to be where I first encountered this pattern, because that's where you often want multiple ways to initialize an object with different combinations of parameters.

What I learned

Constructor overloading in TypeScript is straightforward once you understand that the implementation signature has to be a superset of all the overload signatures. The tricky bit is when you have overloads that look similar but take different types - that's when you need union types and runtime type checking to make it work.

Using neutral parameter names in the implementation helps avoid confusion about what types you're actually dealing with. And edge case testing matters - falsy values like empty strings and zeros are valid inputs that need explicit test coverage.

The full code is in my learning-typescript repository if you want to see the complete implementation. Thanks to Claudia for helping me understand why that compilation error was pointing at the overload when the problem was in the implementation, and to GitHub Copilot for catching the || vs ?? bug in the PR review.

Righto.

--
Adam