rablentain
rablentain

Reputation: 6715

Compare properties from different classes in Typescript

I have two classes, A and B.

class A {
  id: number;
}

class B {
  id: number;
}

Is it possible to have a function typed so that it only accepts id from class A? Maybe by changing number to a custom type of some sort?

Upvotes: 1

Views: 378

Answers (1)

jcalz
jcalz

Reputation: 327624

It sounds like you want the id property of class A to be of a nominal type compatible with number. If this type is declared as ClassANumber, it would be considered distinct from a regular number type, because they have different declarations and different names. So your ideal behavior might look like this:

class A {
  id: ClassANumber;
  constructor(id: ClassANumber) {
    this.id = id;
  }
}

class B {
  id: number;
  constructor(id: number) {
    this.id = id;
  }

}

function acceptClassAId(id: ClassANumber) { }
acceptClassAId(new A(1).id) // okay
acceptClassAId(new B(1).id) // error, number is not a ClassANumber

Unfortunately, TypeScript's type system is structural, and not nominal. If the types number and ClassANumber have the same structure (that is, they both behave like a number when used), then the compiler will consider them the same type:

type ClassANumber = number;

acceptClassAId(new A(1).id) // no error, as desired
acceptClassAId(new B(1).id) // ALSO no error, 😢

There is no error here because ClassANumber is the same type as number, and so new B(1).id's type, number, is assignable to ClassANumber.


There are various tricks you can use to simulate nominal types in TypeScript; you can see the Nominal Typing section of @basarat's TypeScript Deep Dive, or the TypeScript FAQ entry for "Can I make a type alias nominal"?. The canonical place to discuss/consider/lament this is probably microsoft/TypeScript#202, an open issue since 2014.

The one I'll present here is from the "Can I make a type alias nominal" FAQ entry. It uses so-called type branding to intersect the number primitive with an object type containing a single property which can be used to distinguish number from ClassANumber:

type ClassANumber = number & { __classAId: true };

Now the compiler can tell the difference:

acceptClassAId(new A(1).id) // okay
acceptClassAId(new B(1).id) // error!
// Argument of type 'number' is not assignable to parameter of type 'ClassANumber'.

This is just a workaround, of course. Technically you're lying to the compiler: at runtime, a ClassANumber will just be a number, without a __classAId property. And so there are various warts that crop up at compile time. For example, since there's no way to turn a number into a ClassANumber for real, you'll have to use something like a type assertion in any code that needs to do this:

class A {
  id: ClassANumber;
  constructor(id: number) {
    this.id = id as ClassANumber; // need to assert here
  }
}

And once you do have something the compiler believes is a ClassANumber, you'll see this phantom __classAId property you need to steer clear of:

new A(2).id.__classAId.valueOf(); // accepted by compiler, but error at runtime

So proceed with caution.

Playground link to code

Upvotes: 1

Related Questions