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.

1 Answer 1

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

Sign up to request clarification or add additional context in comments.

1 Comment

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<K extends PropertyKey, T extends Record<K, number>> implements Effect<T> So, I ended up needing the T anyways for the `Effect<T>.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.