1

I have a class:

class AdderEffect<T, K extends keyof T> {
  key: K;
  value: T[K] & number;

  constructor(key: K, value: T[K] & number) {
    this.key = key;
    this.value = addend;
  }

  apply(state: T): T {
    // do arithmetic things and return a copy
    return Object.assign({}, state, {
      [this.key]: this.value + state[this.key]
    });
  }
}

The idea is that T is a generic object, K is a key on that object. However, as part of the apply method, I'd like to be able to perform an arithmetic operation on that key. So, I need to restrict K in such a way that it's associated value type is number.

A usage might look like:

interface Point = {
  x: number;
  y: number;
};

const obj: Point = { x: 1, y: 2 };
const adder = new AdderEffect<Point, 'x'>('x', 3);

adder.apply(obj); // returns { x: 4, y: 2 }

whereas

interface Animal = {
  name: string
}

const adder = new AdderEffect<Animal, 'name'>({ name: 'Fido' }, 1);

should fail.

This really feels clunky (especially that <Point, 'x'>('x', 3) business...) so I'm curious if there's a nice way to restrict K such that T[K] extends number.

Chip Bell
  • 65
  • 4

1 Answers1

2

It is possible to make a constraint on K that enforces that T[K] extends number for a given T: I usually call this KeysMatching and you'd write K extends KeysMatching<T, number>. See this question for an implementation.

I'm not including that as the answer here, though, because I don't think it really helps you much. Partly because the compiler will not understand that T[KeysMatching<T, number>] will be compatible with number (it would require a higher-order sort of generic type reasoning than currently supported), and you'd end up needing a lot of type assertions to move forward. But more than that: your AdderEffect doesn't take a value of type T until you call the apply() method, leading me to believe that AdderEffect should only be generic in K and not T.


So, my inclination would be to turn your constraint around... let K be any property key, and have T be constrained to something with a number at T[K]. This is easier to express: T extends Record<K, number> and easier for the compiler to reason about.

And since an instance of AdderEffect doesn't really care about T until you call apply(), I'd move the T generic off of AdderEffect and onto the apply() method. Something like this:

class AdderEffect<K extends PropertyKey> {
  key: K;
  value: number;

  constructor(key: K, value: number) {
    this.key = key;
    this.value = value;
  }

  apply<T extends Record<K, number>>(state: T): Omit<T, K> & { [P in K]: number } {
    return Object.assign({}, state, {
      [this.key]: this.value + state[this.key]
    });
  }

}

I also decided to make apply()'s return value not be of type T, but of type Omit<T, K> & Record<K, number>. This is a subtle difference but could be important if you ever pass in a type T where T[K] is narrower than number, such as a numeric literal type or union of numeric literals.


Let's see how it works. First, we don't have to specify any generics when we create an AdderEffect:

const adder = new AdderEffect('x', 3);
// const adder: AdderEffect<"x">

The K type is inferred to be "x" above. Now, your Point should work:

interface Point {
  x: number;
  y: number;
};
const obj: Point = { x: 1, y: 2 };
const newPoint: Point = adder.apply(obj); // okay

And something else where the x property exists but is not a number, will not:

interface SomethingElse {
  x: string;
  y: number;
}
const els: SomethingElse = { x: "one", y: 2 };
adder.apply(els); // error! 
// -------> ~~~ 
// string is not assignable to number

Finally, let's see what happens in the case where the x property is narrower than number:

interface BinaryPoint {
  x: 0 | 1;
  y: 0 | 1;
}
const bin: Point = { x: 1, y: 1 };

const somethingNew = adder.apply(bin);
// const somethingNew: Pick<Point, "y"> & { x: number; }
// equivalent to const somethingNew: { x: number, y: 0 | 1 }

const newBinaryPoint: BinaryPoint = adder.apply(bin); // error!
// -> ~~~~~~~~~~~~~~
// number is not assignable to 0 | 1

You can apply the adder to a BinaryPoint, but what comes out is no longer a BinaryPoint but a value of a type like { x: number, y: 0 | 1 }. So it will let you call it but it won't let you assign the result to a BinaryPoint.


Okay, hope this helps; good luck!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Wow, super helpful! Thank you very much! This worked perfectly. After posting I realized an omission that changed some implementation. Here's my _real_ class definition: `class AdderEffect> implements Effect ` So, I ended up needing the T anyways for the `Effect. – Chip Bell Jun 18 '20 at 03:03