Search code examples
typescripttypestypescript-types

Conditional type for additional constraint on enum mapping


In my project I have two enums SourceEnum and TargetEnum. For both of the enums, a function exists, which is called with some parameters that depend on the enum value. The exact type of the expected parameters is defined by the two type mappings SourceParams and TargetParams.

enum SourceEnum {
  SOURCE_A = 'SOURCE_A',
  SOURCE_B = 'SOURCE_B'
}

enum TargetEnum {
  TARGET_A = 'TARGET_A',
  TARGET_B = 'TARGET_B',
}

interface SourceParams {
  [SourceEnum.SOURCE_A]: { paramA: string };
  [SourceEnum.SOURCE_B]: { paramB: number };
}

interface TargetParams {
  [TargetEnum.TARGET_A]: { paramA: string };
  [TargetEnum.TARGET_B]: { paramB: number };
}

function sourceFn<S extends SourceEnum>(source: S, params: SourceParams[S]) { /* ... */ }

function targetFn<T extends TargetEnum>(target: T, params: TargetParams[T]) { /* ... */ }

I have a mapping that contains a function to evaluate a target value for each source value and what I want to do is, ensure that the params object used to call sourceFn(x, params) will also work for the call targetFn(mapping[x](), params). To achive this, I created this type:

type ConstrainedMapping = {
  [K in SourceEnum]: <T extends TargetEnum>() => (SourceParams[K] extends TargetParams[T] ? T : never) 
};

const mapping: ConstrainedMapping = {
  [SourceEnum.SOURCE_A]: () => TargetEnum.TARGET_A;
  // ...
}

But then defining the mapping like above gives me the following error:

Type 'TargetEnum.TARGET_A' is not assignable to type '{ paramA: string; } extends TargetParams[T] ? T : never'.

My typing looks clear to me, so I don't really understand what the problem is here. I suppose typescript isn't capable of narrowing down the exact enum value at some point.

Is there a way to achive this? I'm currently working on Typescript 4.2, but I also tried it on 4.3 and 4.4-beta and all show the same behavior. A solution in 4.2 would be highly appreciated, but also a solution in a future version would be fine by me.


Solution

  • So what you are expecting here is for Typescript to see <T extends TargetEnum>(): SourceParams[K] extends TargetParams[T] ? T : never;, and then distribute all possible values of T into this condition and create a union of the ones where it is true.

    Problem is, Typescript isn’t doing that. It’s treating T as unknown, and not evaluating this expression any further than substituting { paramA: string; } for SourceParams[K]. The kind of distribution you’re looking for only occurs in Distributive Conditional Types. So we have to rewrite your ConstrainedMapping to use one.

    A Distributive Conditional Type is a conditional type alias in which none of the parameters are constrained. So to begin with, we need an actual type declaration for this return value, we can’t just put it in the larger declaration of ConstrainedMapping. (Yes, this is weird—the fact that types behave differently if pulled out into its own alias is one of my biggest problems with Typescript’s design.) Like so:

    type TargetWithMatchingParams<S, T> =
      S extends SourceEnum
        ? T extends TargetEnum
          ? SourceParams[S] extends TargetParams[T]
            ? T
            : never
          : never
        : never;
    

    We can’t constrain S or T, so we have to use more conditions within the template for that. (Yes, this is also weird; it’s rather counter-intuitive for that to change the behavior like this.) We also can’t simply hardcode the entire TargetEnum here, even though that’s ultimately what we want—the distribution has to be across an unconstrained param to the type alias.

    When we have done that, then we can use it in ConstrainedMapping:

    type ConstrainedMapping = {
      [S in SourceEnum]: () => TargetWithMatchingParams<S, TargetEnum>;
    };
    

    Note that the function is no longer generic—that was part of your problem—and that we achieve the distribution you were looking for, instead, by passing TargetEnum into TargetWithMatchingParams.

    By the way, if your real case is static like your example, removing the () => from the definition of ConstrainedMapping and “calling” mapping with mapping[x] instead of mapping[x]() would be marginally more performant, and arguably easier to read.

    Finally, there are a couple of limitations to be aware of here.

    1. Calling mapping on a generic variable that extends SourceEnum doesn’t work. That is, targetFn(mapping[SourceEnum.SOURCE_A](), { paramA: 'foo' }) works, but Typescript chokes on something like this:

      function bar<S extends SourceEnum>(src: S, params: SourceParams[S]) {
        targetFn(mapping[src](), params);
                                 ^^^^^^
      //                         Argument of type 'SourceParams[S]'
      //                           is not assignable to parameter of type
      //                           'TargetParams[ConstrainedMapping[S]]'.
      }
      

      That is, Typescript isn’t smart enough to truly understand the relationship between SourceParams and TargetParams, and won’t be able to recognize that any valid SourceParams value will always match the corresponding TargetParams.

    2. In a situation where we accept a union of sources and a union of source params, basically the non-generic version of the above, Typescript allows unsafe values. Consider:

      function baz(src: SourceEnum, params: SourceParams[SourceEnum]) {
        targetFn(mapping(src), params);
      }
      baz(SourceEnum.SOURCE_A, { paramB: 42 });
      

      This will not cause any errors, even though we have SOURCE_A and paramB and that’s an invalid combination. Ultimately, though, this is just a limitation on how Typescript unions work—the problem is in the definition of baz, or the definition of SourceParams/TargetParams, and would be exactly the same if baz called sourceFn without mapping entering into it at all.

    The too-long, didn’t-read version here is just make sure you call sourceFn and targetFn with a specific enum type, not the whole enum. Typescript doesn’t keep track of relationships between separate variables, so it won’t be checking if your unknown SourceEnum and unknown SourceParams[SourceEnum] actually go together, and no definition of mapping is going to solve that problem.