typescript type infer with settings

163 views Asked by At

In the follow example, getValue function can not get the correct type.
Is there any way to infer type by configurable settings?
Example Play

For example

class User {
    name: string;

    constructor(name: string) {
        this.name = name;
    }
}

class Dog {
    name: string;

    constructor(name: string) {
        this.name = name;
    }
}

class Cat {
    name: string;

    constructor(name: string) {
        this.name = name;
    }
}

const settings = [
    { key: 'a', value: 'string' },
    { key: 'b', value: 123 },
    { key: 'c', value: true },
    { key: 'dog', value: Dog },
    { key: 'cat', value: Cat },
    { key: Dog, value: Cat },
    { key: Cat, value: Dog },
    { key: User, value: User },
]

function getValue(key: any) {
    const item = settings.find(obj => obj.key === key);
    if (item) {
        const { value } = item
        if (typeof value === 'function') {
            return new value('test value');
        } else {
            return value;
        }
    } else {
        throw new Error('not found');
    }
}

Of course, I can use as Dog to force cast type.
const dog = getValue('dog') as Dog;
But I think it is a bit redundant.
Is there any better way to solve this problem?

---edit 2020-10-20---
In fact, the settings is not pre-defined.
It could be modified in the runtime.

Finally I want to implement a function like angular dependency injection function.
The settings's structure is like this:

[
{provide: User, useValue: new User('Tom')},
{provide: Cat, useClass: Cat},
{provide: Dog, useClass: Dog},
{provide: AnotherDog, useExiting: Dog},
{provide: AnotherCat, useFactory: function(user) { return user.cat; }, deps: [User]},
]

And the settings is not pre-defined. It could be modified in the runtime.

eg.

let settings = [
    { key: Cat, value: Cat },
    { key: Dog, value: Dog },
]
const cat1 = getValue(Cat); // cat1 is Cat
const dog1 = getValue(Dog); // dog1 is Dog

settings = [
    { key: Cat, value: Dog },
    { key: Dog, value: Cat },
]
const cat2 = getValue(Cat); // cat2 is Dog
const dog2 = getValue(Dog); // dog2 is Cat

That's what I mean settings could be modified in the runtime.

eg.2

let settings = [
    { key: Cat, value: Cat },
    { key: Dog, value: Dog },
]
// cat1 is Cat, getValue return type default as Parameter Cat
const cat1 = getValue(Cat);
// dog1 is Dog, getValue return type default as Parameter Dog
const dog1 = getValue(Dog);

settings = [
    { key: Cat, value: Dog },
    { key: Dog, value: Cat },
]
// cat2 is Dog, getValue return type is set by generic type Dog
const cat2 = getValue<Dog>(Cat);
// dog2 is Cat, getValue return type is set by generic type Cat
const dog2 = getValue<Cat>(Dog);

Can getValue function be implemented like this by optional generic type?

2

There are 2 answers

1
Miro On
const settings = [
    { key: 'a', value: 'string' },
    { key: 'b', value: 123 },
    { key: 'c', value: true },
    { key: 'dog', value: Dog },
    { key: 'cat', value: Cat },
    { key: Dog, value: Cat },
    { key: Cat, value: Dog },
    { key: User, value: User },
] as const;

type SettingsKey = typeof settings[number]['key'];

function getValue(key: SettingsKey) {
    const item = settings.find(obj => obj.key === key);
    if (item) {
        const { value } = item
        if (typeof value === 'function') {
            // const value: new (name: string) => User | Dog | Cat
            return new value('test value');
        } else {
            return value;
        }
    } else {
        throw new Error('not found');
    }
}
5
jcalz On

In your example, User, Cat, and Dog are structurally identical and therefore TypeScript treats them as the same type. This is probably not desirable, so I will give them some differing structure:

class User {
    name: string;
    password: string = "hunter2";
    constructor(name: string) {
        this.name = name;
    }
}

class Dog {
    name: string;
    bark() {

    }
    constructor(name: string) {
        this.name = name;
    }
}

class Cat {
    name: string;
    meow() {

    }
    constructor(name: string) {
        this.name = name;
    }
}

Now the compiler can tell them apart.


My approach here would be to make getValue() a generic function whose return value is a conditional type:

type Settings = typeof settings[number];
type SettingsValue<K extends Settings["key"]> = Extract<Settings, { key: K }>["value"];
type InstanceIfCtor<T> = T extends new (...args: any) => infer R ? R : T;
type SettingsOutputValue<K extends Settings["key"]> = InstanceIfCtor<SettingsValue<K>>;

declare function getValue<K extends Settings["key"]>(key: K): SettingsOutputValue<K>;

The Settings type is just a union of all {key: K, value: V} element types in settings. Then, SettingsValue<K> takes a K which is one of the key types of Settings, and returns the corresponding value type.

The type InstanceIfCtor<T> takes a type T and, if T is a constructor type, returns its corresponding instance type; otherwise it returns T.

And SettingsOutputValue<K> first gets the value type SettingsValue<K>, and then evaluates InstanceIfCtor<> of it to find the type that getValue() should return. If K is one of the types for which SettingsValue<K> is not a constructor, then the return type is just SettingsValue<K>. Otherwise, if SettingsValue<K> is a constructor, then SettingsOutputValue<K> is the instance type of that constructor.


Implementing getValue() can't really be done in a way that the compiler verifies as safe, since it can't follow the higher order reasoning needed to convert K into SettingsOutputValue<K> for an unspecified K. I usually just do this as a single call-signature overload to loosen the type checking, keeping in mind that I need to be careful that my implementation is really doing what the call signature says it's doing:

function getValue<K extends Settings["key"]>(key: K): SettingsOutputValue<K>;
function getValue(key: Settings["key"]) {
    const item = settings.find(obj => obj.key === key);
    if (item) {
        const { value } = item
        if (typeof value === 'function') {
            return new value('test value');
        } else {
            return value;
        }
    } else {
        throw new Error('not found');
    }
}

Okay, let's test it:

const dog = getValue('dog'); // Dog
dog.bark();
const cat = getValue('cat'); // Cat
cat.meow();
const user = getValue(User); // User
user.password.toUpperCase();

Looks good. The dog, cat, and user variables are inferred to be of type Dog, Cat, and User, respectively.


Playground link to code