简体   繁体   中英

How to infer/narrow-down a function parameter type based on the narrowed-down type of another function parameter?

In my application, there are two color formats: hex and rgb. A hex color is stored as a string, and an rgb color is stored as an object that adheres to the following interface: { r: number; g: number; b: number; a: number } { r: number; g: number; b: number; a: number } { r: number; g: number; b: number; a: number } . My type definitions look like this:

type Color<T> =
  T extends 'hex' ? string :
  T extends 'rgb' ? ColorRgb :
  never

type ColorRgb = { r: number; g: number; b: number; a: number }

// Infer color value type via color format
type T0 = Color<'hex'> // string ✅
type T1 = Color<'rgb'> // ColorRgb ✅

Here, T0 and T1 are inferred as expected.

Problem statement

I'm having trouble writing a function for which one parameter is narrowed down based on the value of another parameter. The following piece of code is my starting point. It's a function that takes format and value parameters. What exactly it does matters not.

What I want to accomplish is this: If I narrow down the value of the format parameter (eg an if statement checking whether format === 'hex' ), I want TypeScript to somehow infer the type of the value parameter. When format === 'hex' , value should be of type string ; when format === 'rgb' , value should be of type ColorRgb .

function processColor (format: 'hex' | 'rgb', value: string | ColorRgb) {
  if (format === 'hex') {
    console.log(format, value) // format: 'hex' ✅, value: string | ColorRgb ❌
  } else {
    console.log(format, value) // format: 'rgb' ✅, value: string | ColorRgb ❌
  }
}

Approach 1: function overloads

One approach I followed was using function overloads .

function processColor2 (format: 'hex', value: string);
function processColor2 (format: 'rgb', value: ColorRgb);
function processColor2 (format: 'hex' | 'rgb', value: string | ColorRgb) {
  if (format === 'hex') {
    console.log(format, value) // format: 'hex' ✅, value: string | ColorRgb ❌
  } else {
    console.log(format, value) // format: 'rgb' ✅, value: string | ColorRgb ❌
  }
}

It appears to me that function overloads are a way to specifically make calling a function more restrictive as this implementation doesn't change the situation with regards to my goal at all.

Approach 2: generic function with constraints

Another approach I looked into was using a generic function with constraints .

function processColor3<T extends 'hex' | 'rgb'> (format: T, value: Color<T>) {
  if (format === 'hex') {
    console.log(format, value) // format: T extends 'hex' | 'rgb' ❌, value: Color<T> ❌
  } else {
    console.log(format, value) // format: T extends 'hex' | 'rgb' ❌, value: Color<T> ❌
  }
}

Here, I'm actually surprised to see that I'm losing type information within the function definition. Despite narrowing down the format parameter to be 'hex' in the if branch, its type is now reported as T extends 'hex' | 'rgb' T extends 'hex' | 'rgb' . I might be misunderstanding what exactly conditional types can accomplish or I might be using them incorrectly.


How can I tell TypeScript to infer a function parameter type based on the narrowed-down type of another function parameter?

Both overloads and generic conditional types are, as you noticed, really only good for guaranteeing type safety for the caller of the function. Inside the implementation of the function, the compiler loses information necessary to maintain type safety.

For overloads this is intentional; it is the implementer's responsibility to satisfy the call signatures' contracts and the compiler doesn't really try to verify it. There was a suggestion to change this at microsoft/TypeScript#13235 but this was closed as too complex.

For generic conditional types the issue is that inside the function implementation, any generic type parameters are unspecified . Conditional types that depend on unspecified type parameters are mostly deferred and not evaluated. The compiler therefore doesn't really see any concrete values as assignable to such types; there is an open issue asking for some way to deal with this, at least for return types, at microsoft/TypeScript#33912 . Even for non-conditional types, unspecified generics tend not to be very easy to manipulate. One major barrier is that testing a value of type T with a type guard (eg, format === 'hex' ) does not do the sort of control flow analysis you get for specific types. In your case, format is not narrowed at all. See microsoft/TypeScript#13995 for more information.

Another barrier to fixing this in general is that the compiler does not keep track of correlations between multiple expressions of union types. In your code, you want to say that format and value are both union of types, but they are not independent . With each union having two members, the compiler will always assume that the pair [format, value] can have up to four possible types corresponding to each member from each union, as if they were independent. Even though you know that the type of format is correlated with the type of value so that only two possible types exist for [format, value] , the compiler just does not notice such things. I have an issue at microsoft/TypeScript#30851 wishing for some support for this, but compilers don't run on wishes.


So what can you do? All you can do in the general case is to use type assertions or the equivalent to just tell the compiler about things it can't verify. For example, you could make an assertion function that does nothing at runtime but tells the compiler to narrow the type of its parameter to some specified type, like this:

function compileTimeAssert<T>(x: any): asserts x is T {}

And then inside your implementation you can use it:

    if (format === 'hex') {
        compileTimeAssert<string>(value); // I'm telling the compiler this
        console.log(format, value.toUpperCase());
    } else {
        compileTimeAssert<ColorRgb>(value); // I'm telling the compiler this
        console.log(format, value.r);
    }

Such assertions are only as type safe as you make them; the responsibility for guaranteeing type safety has been shifted from the compiler to you, so be careful.


In the specific example you've given, I'd be inclined to change Color from a conditional type to an object property lookup like this:

interface ColorMap {
    hex: string;
    rgb: ColorRgb;
}
type Color<T extends keyof ColorMap> = ColorMap[T];

It's not necessary to do this, but it's easier for the compiler to reason about. Unless there's a compelling reason why you want to be able to write Color<Date> and have it evaluate to never , I'd do it that way.

Then, I'd suggest thinking of processColor() as a function whose rest parameter is a tuple type . So instead of (a: A, b: B)=>void , it's (...args: [A, B])=>void . Your constraint corresponds to having the rest parameter type be a union of tuples. You can make the compiler calculate this type:

type ProcessColorParams =
    { [K in keyof ColorMap]: [format: K, value: Color<K>] }[keyof ColorMap];
// type ProcessColorParams = 
//   [format: "hex", value: string] | [format: "rgb", value: ColorRgb]

You can see that ProcessColorParams is a union where either the first element is "hex" and the second element is string , or where the first element is "rgb" and the second element is ColorRgb . And since the first element is a string literal type , this union is a discriminated union , where you can check the first element and the compiler will automatically narrow down the remaining elements.

So processColor can become tihs:

function processColor(...args: ProcessColorParams) {
    if (args[0] === 'hex') {
        console.log(args[0], args[1].toUpperCase());
    } else {
        console.log(args[0], args[1].r);
    }
}

Now the constraint is enforced from the call side and understood in the implementation. Yes, args[0] and args[1] are uglier than format and value , but if you break apart a discriminated union into separate variables you'll run into the problem with correlation I mentioned above. If you want to do this, do it after you discriminate the union:

function processColor(...args: ProcessColorParams) {
    if (args[0] === 'hex') {
        const [format, value] = args;
        console.log(format, value.toUpperCase());
    } else {
        const [format, value] = args;
        console.log(format, value.r);
    }
}

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