Why Your Cat Became a Dog (And TypeScript Finally Noticed)
Discover how JavaScript private fields change TypeScript from structural typing to nominal typing, creating hard boundaries between classes that look identical.
TypeScript lets you assign a Dog to a Cat variable, even though they're different classes. This surprising behavior stems from TypeScript's structural type system, where identical shapes are interchangeable. But there's a simple way to prevent this: JavaScript private fields. Let's explore why TypeScript allows this seemingly broken code and how to fix it.
Contents
- The Surprising Problem
- The Problem with Structural Typing
- Private Fields enforce Nominal Typing
- Practical Use Cases
- When Bundlers Create Multiple Instances
The Surprising Problem
Look at this TypeScript code. Can you spot the issue?
class Cat {
name: string = 'Garfield';
}
class Dog {
name: string = 'Scooby-Doo';
}
const cat: Cat = new Dog(); // โ
No error!TypeScript happily accepts this code. A Dog instance is assigned to a variable typed as Cat, and the compiler doesn't complain. It's a fundamental feature of how the language works because TypeScript uses structural typing.
If two types have the same structure (the same properties and function signatures), they're considered compatible, regardless of their names or how they were declared. TypeScript doesn't care that one class is called Cat and the other Dog. It only cares about their shape.
Structural typing extends beyond objects to functions as well. TypeScript compares function types by examining their parameters and return types. Here's where things get interesting: a function with more parameters can be assigned to a type expecting fewer parameters:
let shortFunction = (value: number) => 0;
let longFunction = (value: number, index: number) => 0;
longFunction = shortFunction; // โ
Allowed!
const result = shortFunction(42); // โ
Works!
shortFunction = longFunction; // โ Error! TS2322How can a function that expects two parameters be used where only one is provided? The answer lies in JavaScript's flexibility. When you call a function with fewer arguments than it expects, the extra parameters become undefined.
This pattern appears frequently when working with array methods. Consider Array.map(), which passes three arguments to its callback (value, index, array), but you can use a callback that only accepts one:
const numbers = [1, 2, 3];
const shortFunction = (value: number) => value * 2;
const doubled = numbers.map(shortFunction);The callback only uses the first parameter (value), ignoring the index and array. TypeScript's structural typing makes this natural JavaScript pattern type-safe.
The Problem with Structural Typing
While structural typing is useful, it can lead to bugs when you want classes to be distinct types. In our example, Cat and Dog represent different domain concepts. Allowing them to be used interchangeably could cause logic errors.
Imagine a veterinary system where cats and dogs have different medical requirements. If you accidentally pass a Dog to a function expecting a Cat, you might administer the wrong treatment. The type system should catch this mistake, but structural typing allows it.
You need a way to make objects nominally distinct while still writing idiomatic TypeScript. This is where branded types come in, which work well for primitive values and simple objects. For classes, you can also use private fields, which offer a JavaScript-native approach.
Private Fields enforce Nominal Typing
TypeScript treats private fields as nominally typed. Each class's private fields are unique to that class, even if they have the same name. This makes classes with private fields structurally incompatible, even if their public interfaces are identical.
Let's add private fields to our classes:
class Cat {
#name: string = '';
}
class Dog {
#name: string = '';
}
// โ Error: Type 'Dog' is not assignable to type 'Cat'.
// Property '#name' in type 'Dog' refers to a different member
// that cannot be accessed from within type 'Cat'.
const cat: Cat = new Dog();Now TypeScript throws an error. Even though both classes have a field called #name, they're different private fields. Cat's #name and Dog's #name are distinct, incompatible members.
This creates a hard boundary between the classes. You cannot access one class's private field from another class, and TypeScript recognizes this at the type level. The classes are no longer structurally compatible, even though they have identical public APIs.
The same effect applies when class members are marked with the private access modifier.
Practical Use Cases
Private fields are especially valuable when modeling domain concepts that should be distinct types, even if they happen to have similar data structures.
Consider a payment processing system with different currency types:
class USD {
#brand!: void;
constructor(public readonly amount: number) {}
}
class EUR {
#brand!: void;
constructor(public readonly amount: number) {}
}
function processUSDPayment(payment: USD) {
console.log(`Processing $${payment.amount}`);
}
const dollars = new USD(100);
const euros = new EUR(100);
processUSDPayment(dollars); // โ
Correct
processUSDPayment(euros); // โ ErrorThe #brand field serves as a phantom type. It's never used at runtime (note the void type and ! assertion), but it makes each currency class nominally distinct. This prevents accidentally mixing currencies in calculations, a common source of bugs in financial software.
When Bundlers Create Multiple Instances
PPrivate fields can hide a subtle yet serious issue with module bundlers. If the bundler unintentionally produces multiple copies of the same class, for example through different import paths or code splitting, you can end up with classes that look identical but are treated as distinct. When your code is published as a library, this can lead to unexpected type incompatibilities.
Consider this project structure:
export class Cat {
#name: string = '';
}Now suppose you have two different export barrels:
export { Cat } from './Cat';export { Cat } from '../models/Cat';If your bundler setup pulls in the Cat class more than once, which can happen when using multiple bundle entry points that generate separate output chunks, each chunk may contain its own distinct copy of Cat.
At runtime, this can cause confusing issues where one Cat instance does not match the Cat type another function expects. This problem often surfaces as the TypeScript error "TS2345", with a message indicating that the #private property refers to a different member.
