Mike Lischke
Mike Lischke

Reputation: 53337

Wrong automatic return type deduction in Typescript

In a settings class I have a method to get a value, with an optional default value to return if the given key cannot be found:

    /**
     * Returns the value stored at the given key, which can have the form `qualifier.subKey`.
     *
     * @param key The key to look up.
     * @param defaultValue An optional value to be returned if the there's no value at the given key or the key doesn't
     *                     exist at all.
     *
     * @returns If a return value is given the return type is the same as that of the default value. Otherwise it's
     *          either undefined or the same as the type found at the given key.
     */
    public get<T>(key: string, defaultValue: T): T;
    public get(key: string): any;
    public get<T>(key: string, defaultValue?: T): T | undefined {
        const { target, subKey } = this.objectForKey(key, false);
        if (!target || !subKey) {
            return defaultValue;
        }

        return target[subKey] as T ?? defaultValue;
    }

This implementation gives me return types I did not expect. Consider this call:

const removeIdleTime = settings.get("workers.removeIdleTime", 60);

The variable removeIdleTime is not of type number as I would expect, but of type 60. I can explicitly use <number> as template/generic parameter to get and the result will then be ok, but it would be way more cool to have Typescript deduce the right type. What must be changed to accomplish that?

Update

I just found the description about type widening in Typescript (I didn't know the correct term at the time of writing this question). It turns out that the type is widended when assigning the result of get to a mutable variable. Otherwise it stays a literal type.

While this is interesting information, it doesn't help with this question, because linters will usually convert any let to const if they are not changed after the initial assignment.

Upvotes: 3

Views: 511

Answers (1)

Mike Lischke
Mike Lischke

Reputation: 53337

Solution

Thanks to @Etheryte I found a solution: there's a way to enforce type widening by using conditional types.

export type ValueType<T> = T extends string
    ? string
    : T extends number
        ? number
        : T extends boolean
            ? boolean
            : T extends undefined
                ? undefined
                : [T] extends [any]
                    ? T
                    : object;

which can be used so (note the only change, for the return type):

    /**
     * Returns the value stored at the given key, which can have the form `qualifier.subKey`.
     *
     * @param key The key to look up.
     * @param defaultValue An optional value to be returned if the there's no value at the given key or the key doesn't
     *                     exist at all.
     *
     * @returns If a return value is given the return type is the same as that of the default value. Otherwise it's
     *          either undefined or the same as the type found at the given key.
     */
    public get<T>(key: string, defaultValue: T): ValueType<T>;
    public get(key: string): any;
    public get<T>(key: string, defaultValue?: T): T | undefined {
        const { target, subKey } = this.objectForKey(key, false);
        if (!target || !subKey) {
            return defaultValue;
        }

        return target[subKey] as T ?? defaultValue;
    }

This also works for enums, where the conditional type returns number or string, depending on the base type of the enum.


Previous Answer

This behavior is by design. Literal values that are assigned to constant targets keep their literal type. This follows the principle of always storing the most narrow type. In situations where values can be changed (e.g. by assigning a literal to a mutable variable) the Typescript transpiler widens the type to allow for other values than the initial literal. You can read a bit more about that in the article Literal Type Widening in TypeScript.

There's no way (I know of) to force type widening, so I see 3 possible ways here:

  1. Use a mutable target when you assign the result from the get call. This might be problematic, because linters will try to "optimize" the variable to become immutable, if there are no other assignments to it.

  2. Add an explicit type annotation to the target, like:

const removeIdleTime: number = settings.get("workers.removeIdleTime", 60);

  1. Specify the generic parameter explicitly:

const removeIdleTime = settings.get<number>("workers.removeIdleTime", 60);

or

const removeIdleTime = settings.get("workers.removeIdleTime", 60 as number);

All these ways are not really a solution to my question, though.

Upvotes: 1

Related Questions