简体   繁体   中英

Generics in nested TypeScript function

I want to write a TypeScript function that accepts only certain strings, which are the property names of a specified type, but these properties must have a type of string , number or Date . This function returns another function that accepts the original object (of which the property names were selected) and returns the specified property (actually it does something else, but this is the simplest case that produces the same problem)

I tried it like this

"use strict";
type InstancePropertyNames<T, I> = {
    [K in keyof T]: T[K] extends I ? K : never;
}[keyof T];
type StringPropertyNames<T> = InstancePropertyNames<T, string>;
type NumberPropertyNames<T> = InstancePropertyNames<T, number>;

function doSomething<T extends object>(
    key:
        | StringPropertyNames<T>
        | NumberPropertyNames<T>
        | InstancePropertyNames<T, Date>
) {
    return function(obj: T) {
        const res = obj[key];
        return res;
    }
}
function doSomethingEasy<T extends object>(
    obj: T,
    key:
        | StringPropertyNames<T>
        | NumberPropertyNames<T>
        | InstancePropertyNames<T, Date>
) {
    const res = obj[key];
    return res;
}

type Test = {
    asdf: string;
    foo: number;
    bar: boolean;
    qux: Date;
};

const test: Test = {
    asdf: "asdf",
    foo: 42,
    bar: true,
    qux: new Date()
}

const res = doSomething<Test>("asdf")(test);
console.log(res);
const resEasy = doSomethingEasy(test, "asdf");
console.log(resEasy);

TypeScript Playground

The problem is now that the type of res inside the nested function is something complex (instead of simply number | string | Date ), also in doSomethingEasy . Is there any way to simplify the type of the property in the nested function to the primitive types?

Your doSomething() is a curried function where you don't know the type that T should be until you call the function returned by doSomething() . This makes it nearly impossible for the compiler to infer T . To make this obvious, imagine this:

const f = doSomething("asdf"); // what type should f be?
f({asdf: 123});
f({asdf: "a"});
f(test);

The parameter T gets inferred by the compiler when you call doSomething("asdf") to produce f . But what should it be inferred to be? Should it depend on whatever you happen to call f() with? And what if you call f() with different types like I did above? The only thing that would work here is if f is itself a generic function, but the compiler does not know enough to try to infer that.

So you get T inferred as something like {asdf: any} (because reverse inference through conditional types like InstancePropertyNames is also essentially impossible), and then f() spits out any whatever you call it with. Oops.


Instead what I'd do here is try to write your signature so that at each step the compiler only needs to know the type a function's parameters in order to infer its type parameters. Something like this:

function doSomething<K extends PropertyKey>(
    key: K
) {
    return function <T extends Record<K, string | number | Date>>(obj: T) {
        const res = obj[key];
        return res;
    }
}

Here doSomething will accept a key of type K which can be any key-like value. Since key determines K , you can assume that K will be inferred properly. We don't make doSomething() generic in T since nothing passed to doSomething() will help determine what T should be.

The return value of doSomething() is another generic function which accepts an obj of type T which is constrained to Record<K, string | number | Date> Record<K, string | number | Date> Record<K, string | number | Date> . So we pushed the T generic into the signature for the return function, which should actually know enough to infer it.

(This returned function doesn't have to be generic in T ; you could have type obj as Record<K, string | number | Date> directly, but then excess property checks kick in where you might not want them, and also the return value of the returned function would always be string | number | Date which is wider than you might want. Benefits of keeping T around below:)

Since K is already known, this should mean that T will be inferred as the type of obj and it will only accept obj parameters whose value at the K key are assignable to string , number , or Date . And the return type of the returned function will be T[K] , the type of the property of T at key K .

Let's see if it works:

const f = doSomething("asdf");
// const f: <T extends Record<"asdf", string | number | Date>>(obj: T) => T["asdf"]

const res = doSomething("asdf")(test); // string
console.log(res); // "asdf"

console.log(
  doSomething("bar")({foo: true, bar: new Date(), baz: 123}).getFullYear()
); // 2020

Looks good. The call to doSomething("asdf") returns a generic function accepting values assignable to {asdf: string | number | Date} {asdf: string | number | Date} {asdf: string | number | Date} . And so doSomething("asdf")(test) produces a value of type string . The call at the end is shows the versatility of this implementation; you get a Date out because the compiler knows that the bar property of the passed-in object literal is a Date . (That's why having both K and T be generic is useful. Otherwise Record<K, string | number | date>[K] is just string | number | date . The type T[K] is narrower and probably more helpful to you.)

Playground 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