Home > Blockchain >  TypeScript generic conditional type as return value for generic function
TypeScript generic conditional type as return value for generic function

Time:01-02

Background

As part of a data analysis library I'm working on, I'm creating a set of functions that read certain types of values out of strings. The idea is essentially to define the structure of a CSV file, so I can read each of its cells as a string and, knowing what type it's supposed to be, convert that string into an appropriately typed value.

Some cells contain multiple values, and get converted into arrays. Other cells contain single values.

Part of what I'm doing here is removing values that don't match the expectations, and logging a warning. For cells containing arrays, I'm quite happy with the result still being an array even if it's empty. But for cells containing single values, I want to convert invalid values to null.

To do this, I've set up a generic type to be shared by all these "transformer" functions, which returns a conditional type matching those requirements:

type TransformerFn<T> = (value: string, locationIdentifier?: string) => T extends any[] ? T : (T | null);

In the simple cases I've implemented so far, such as for splitting a string into an array of strings or for extracting boolean value, this is working just fine. TypeScript has had no problem resolving the condition for TransformerFn<string[]> or TransformerFn<boolean>.

But one of the transformers I have is essentially for confirming that every cell in a column is either empty or contains a value in a string enum, and here I've been running into a problem.

I've been typing that string enum as Record<string, string> when using it as a function argument, which has been working fine. However, recently I added some functionality to recode data that should clearly be a particular enum value, but hasn't been loaded correctly.

To accomplish this, I've used a generic type with a constraint to represent the union of the string enum's values:

export function enumValue<E extends string>(enums: Record<string, E>, recodeMap?: Record<string, E>)

This has worked fine, up until I tried to add that TransformerFn typing I mentioned earlier.

The problem

Even though my generic type E has a constraint that it extends string, which means E extends any[] will never be true, TypeScript is failing to resolve my generic conditional type.

Here's the error it's giving me:

Type '(value: string, locationIdentifier?: string | undefined) => E | null' is not assignable to type 'TransformerFn'. Type 'E | null' is not assignable to type 'E extends any[] ? E : E | null'. Type 'null' is not assignable to type 'E extends any[] ? E : E | null'.

Here's my code:

type TransformerFn<T> = (value: string, locationIdentifier?: string) => T extends any[] ? T : (T | null);

/**
 * Checks that the value, if it exists, is a member of an enum.
 *
 * If the value does not exist, it is transformed to null.
 *
 * If a recoding map is passed, and it contains instructions for this value, it is recoded first.
 *
 * If the value exists but it is not a member of the enum and cannot be recoded,
 * a warning will be generated and null will be returned.
 */
export function enumValue<E extends string>(enums: Record<string, E>, recodeMap?: Record<string, E>): TransformerFn<E> {
    const enumValues: E[] = Object.values(enums);

    function isEnumMember(val: unknown): val is E {
        return (enumValues as any[]).includes(val);
    }

    const transformer: TransformerFn<E> = (value: string, locationIdentifier?: string) => {
        if (!value) {
            return null;
        }

        if (isEnumMember(value)) {
            return value;
        }

        if (recodeMap && value in recodeMap) {
            const recodedValue = recodeMap[value];
            return recodedValue;
        }

        console.warn(`Value '${value}' does not exist within ${enumValues.join(', ')} (${locationIdentifier})`);
        return null;
    };

    return transformer;
}

TypeScript Playground

Minimum reproducible example

type TransformerFn<T> = () => T extends any[] ? T : null;

function enumValue<E extends string>(): TransformerFn<E> {
    const transformer: TransformerFn<E> = () => {
        return null;
    };

    return transformer;
}

TypeScript Playground

Type '() => null' is not assignable to type 'TransformerFn'. Type 'null' is not assignable to type 'E extends any[] ? E : null'.


Now of course I could just give up and use a non-conditional type for my enumValue function's return value, since I know it should be E | null, instead of trying to use my TransformerFn conditional type. My code will still work just fine, and it won't actually cause any maintenance issues for me.

But I've run into something I don't understand and haven't been able to figure out. So, can anyone explain to me why this isn't working, and if there's something I could do instead that would work?

CodePudding user response:

This is a current limitation or missing feature of TypeScript; the compiler generally defers evaluation of a conditional type which depends on an unresolved/unspecified generic type parameter, such as inside the implementation of a generic function. This is usually the best you can do, since the compiler can't generally know what the conditional type will be until it knows exactly what the checked type is. But: in some cases the generic type parameter is constrained in such a way that the compiler should be able to evaluate the conditional type earlier.

For example, if you have a constrained generic type parameter T extends A, then anything like T extends A ? X : Y could conceivably be narrowed to X even when T is not known exactly. Or if there's another type B such that A & B is never, then T extends B ? X : Y could conceivably be narrowed to Y even when T is not known exactly.

Your example case is the latter: in the case that E extends string, then the conditional type E extends any[] ? E : null; should evaluate to null, since the type string & any[] is essentially impossible (it's actually not so simple; types like string & any[] do not get reduced to never by the compiler and so it's possible for something other than null to come out there, but let's ignore that wrinkle here).

In any case, this sort of early evaluation of generic conditional types does not happen. There is a suggestion at microsoft/TypeScript#23132 to use generic constraints to evaluate conditional types. That issue is still labeled as "awaiting feedback", so if you think your use case is particularly compelling (and not already mentioned in the issue) then you might want to go over there and describe it. In any case you could give it a

  • Related