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