简体   繁体   中英

How to type a Tuple with type inference in Typescript

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' 'hello' | 'world' and T is { hello: string; world: number; } { hello: string; world: number; } { hello: string; world: number; } , we can pass in ['hello', 42] .

I would like to prevent this, but am unsure how to do so.

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; } { hello: string; world: number; } { 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"] M["a" | "b" | "c"] M["a" | "b" | "c"] or M["a"] | M["b"] | M["c"] M["a"] | M["b"] | M["c"] M["a"] | M["b"] | M["c"] or ["a", string?] | ["b", number?] | ["c", boolean?] ["a", string?] | ["b", number?] | ["c", boolean?] ["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!

Link to code

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM