Skip to content

Nominal Types in TypeScript

by Liam Mills-Tait

Consider a complex application where two objects, though structurally similar, serve entirely different logical purposes. Here, structural typing can lead to subtle, hard-to-detect bugs. Nominal typing offers a safeguard, ensuring objects are used only in their intended context.

In TypeScript, the default behavior for types is structural typing. However, did you know that private properties can also allow the use of nominal types.

class Named {
  #nominal = true;
  constructor(public name: string) {}
}
 
interface INamed {
  name: string;
}
 
function requirePerson(p: Named) {}
 
requirePerson(new Named("Liam"));
// Compile-time error
requirePerson({ name: "Liam" });

In this example, the Named class includes a private property #nominal. This field is not visible outside the class. It serves as a unique marker that differentiates instances of Named from other objects, even if they share the same structure.

While nominal typing can prevent certain bugs, it also introduces more boilerplate, which can reduce code readability or increase the learning curve for new developers unfamiliar with these patterns.

That example is a bit contrived, here are some more realistic examples

Parse, Don't validate

class Email {
  #address: string;
  private constructor(address: string) {
    this.#address = address;
  }
  get address() {
    return this.#address;
  }
  static parse(address: string): Email | null {
    // parse an email or fail to parse and return null
    return new Email(address);
  }
}
 
interface IUser {
  email: Email;
  name: string;
}
 
function updateUser(user: IUser) {
  // Update the user's details
}
 
const validEmail = Email.parse("example@example.com");
if (validEmail === null) {
  throw new Error("Invalid email");
}
updateUser({ email: validEmail, name: "John Doe" });
 
const invalidEmail = { address: "example@example.com" };
// Compile-time error
updateUser({ email: invalidEmail, name: "John Doe" });

Using the Email class with a private constructor and a static parse method ensures that all email objects throughout the application are valid. This prevents common issues such as sending emails to invalid addresses or failing to recognize users correctly based on their email inputs

Entity identifier types

class UserId {
  #id: string;
  constructor(id: string) {
    this.#id = id;
  }
}
 
class AccountId {
  #id: string;
  constructor(id: string) {
    this.#id = id;
  }
}
 
function getAccountById(accountId: AccountId) {}
 
const accountId = new AccountId("account456");
const userId = new UserId("user123");
 
getAccountById(accountId);
// Compile-time error
getAccountById(userId);

This pattern prevents access control errors, where the wrong entity might be accessed due to a simple type mismatch.

Conclusion

After all, this is JavaScript, and these types of implementations come at a cost. The trade-off here is adding additional complexity and verbosity in your codebase for safety.

Structural typing avoids the extra complexity and boilerplate of managing explicit types for everything, making the codebase often more concise.

Using nominal typing requires more boilerplate code, as each nominal type needs to be explicitly defined and managed. Nominal types signal clear and explicit intent to other developers and future maintainers of the code. It reduces the risk of subtle bugs that occur when objects that happen to have the same shape are mistakenly used interchangeably.

Many developers using JavaScript believe that classes introduce unnecessary complexity and syntactic overhead. However, I argue that when using classes in this way, they are more akin to types than to a traditional object-oriented approach. This means treating classes more like 'structs' or 'records' in other languages. Classes are just one of many tools available to developers, with its own use cases and advantages.

Do you have a place where nominal typing in TypeScript would be useful?