Reputation: 81
I'm trying to write the type signature of a function that replaces the values of an object with a list of changes to the object.
I'm having trouble finding the most accurate type for the function.
The function in question is:
export const patchObjFn = (
defaultVal: any
) => <T extends object, K extends keyof T> (
changeObj: Replacement<T>[] | Replacement<T>,
moddingObj: T
) => {
const moddedObj = cloned(moddingObj);
const isSingleElement = changeObj.length !== 0
&& (changeObj.length === 1 || changeObj.length === 2)
&& !Array.isArray(changeObj[0]);
const changes = isSingleElement
? [changeObj] as ([K] | [K, T[K]])[]
: changeObj as ([K] | [K, T[K]])[];
for (const change of changes) {
const [propName, val] = change.length === 1
? [change[0], defaultVal]
: change;
moddedObj[propName] = val;
}
return moddedObj;
};
And the type I've reached is:
export type Replacement<T extends object, K extends keyof T> = [K] | [K, T[K]];
But this doesn't really work, since if K is 'hello' | 'world'
and T is { hello: string; world: number; }
, we can pass in ['hello', 42]
.
I would like to prevent this, but am unsure how to do so.
Upvotes: 1
Views: 508
Reputation: 327984
I'm going to address just the typings and not the implementation. It's possible that this typing will cause some errors to appear inside your implementation; if so, it is likely that either you can change your implementation to appease the compiler or you can assert that your implementation matches the signature. In either case, the question seems to be about how to prevent callers from passing mismatched key/value pairs, and not what's going on inside the function implementation.
I'm also going to ignore the defaultVal
parameter and the curried function that takes it, because it complicates things trying to figure out just what type of object can possibly take a single default value for all its properties. If T
is { hello: string; world: number; }
and I pass in [["hello"],["world"]]
, is defaultVal
somehow both a string
and a number
? 😵 As I said, I'm ignoring that.
So we'll come up with the signature of a function patch
which takes an object of generic type T
and a list of replacement key-value tuples appropriate to T
, and returns a result also of type T
:
declare function patch<T extends object>(
t: T,
kvTuples: Array<{ [K in keyof T]: [K, T[K]?] }[keyof T]> | []
): T;
The interesting part is the type of the kvTuples
parameter. Let's take care of that | []
at the end, there, first. All this does is give the compiler a hint to interpret kvTuples
as a tuple and not as a plain array. If you leave that off it won't change which inputs are accepted or not accepted, but the error messages will become completely incomprehensible as the entire input array will be flagged as wrong:
patch({ a: "hey", b: 1, c: true }, [
["a", "okay"], // error! 😕
["b", false], // error! 👍
["c", true] // error! 😕
]);
// string is not assignable to "a" | "b" | "c" 😕
In the above, the ["b", false]
is the wrong entry, as the type of the b
property should be number
. What you get though is a lot of errors that don't really point you to what you should be fixing.
Anyway, kvTuples
is an Array<Something>
where Something
is a mapped type we do a lookup into. Let's examine that type. {[K in keyof T]: [K, T[K]?]}
takes each property of T
and turns it into a pair of key-value types (with the second element being optional).
So if T
is {a: string, b: number, c: boolean}
, then the mapped type is {a: ["a", string?], b: ["b", number?], c: ["c", boolean?]}
. Then we use a lookup type to get the union of property value types. So if we call the mapped type M
, we are doing M[keyof T]
or M["a" | "b" | "c"]
or M["a"] | M["b"] | M["c"]
or ["a", string?] | ["b", number?] | ["c", boolean?]
. And that's the type we want for each element of the kvTuples
.
Let's try it:
patch({ a: "hey", b: 1, c: true }, [
["a", "okay"],
["b", false], // error! 👍
["c", true]
]);
The error shows up in a reasonable place at least. Less reasonable is the error message you get. The following is at least vaguely okay:
// Type '(string | boolean)[]' is not assignable to type
// '["a", (string | undefined)?] | ["b", (number | undefined)?] |
// ["c", (boolean | undefined)?]'. 😐
Since it didn't type check, the compiler is apparently saying that ["c", 7]
is of type (string | number)[]
, which doesn't match the union of tuples. That's true enough, but it yields an unfortunate last-line of the error message:
// Property "0" is missing in type '(string | boolean)[]'
// but required in type '["c", (boolean | undefined)?]' 😕
That's not helpful, especially since it's talking about "c"
for what seems like no reason to the user. Still, at least the error shows up in the right place. It is undoubtedly possible to make the error even more sensible, at the expense of more complexity in the signature of patch
, which I fear is already complicated enough.
Okay, hope that helps. Good luck!
Upvotes: 1