1

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?

1 Answer 1

2

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>. 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}. 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

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

6 Comments

Thank you for the in depth answer! Since you've replied so fast, I don't know if you've got my edit 2 minutes after the initial post (it isn't visible). I realized that I forgot to pass the type as parameter to doSomething which resolved the return type correctly, but in the function the type of the property is quite complex where it simply could be number | string | Date. Can that be simplified? (except cheating with prop as unknown as string | number | Date)
Ah, I see. Well I’ll look when I get to a real computer, assuming nobody else answers in the meantime.
Okay, well: no, the compiler is not smart enough to reason generically that T[IPN<T, string>] | T[IPN<T, number>] | T[IPNames<T, Date>] will be assignable to string | number | Date (not equal to that because, e.g., the type {a: number} should produce a res of type number). For a specific T it can actually evaluate it, but not for an unspecified generic T. If you want that to happen inside the implementation you should use a type assertion.... or you could use the solution I give in this answer which the compiler does understand.
My suggested solution would still be the code in this answer, since it's more straightforward for the compiler to infer and process. If for some reason you actually like or need to specify T when you call doSomething() and complex conditional InstancePropertyNames constraints, then you will get something complex out and need to use type assertions or the like to deal with it. Hope that helps. Good luck!
Thanks for your suggestions! I tried it with type assertions, but e.g. res as number inside the function gives me an error that the types T[symbol] and number are not compatible... I will try it with your approach next!
|

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.