Home > Mobile >  Enforcing parameter combinations with typescript
Enforcing parameter combinations with typescript

Time:07-28

I have the following code:

enum ParamOneType {
  Round,
  Square,
}

interface PossibleValues {
  [ParamOneType.Round]: 'a' | 'b';
  [ParamOneType.Square]: 'c' | 'd';
}

const indexRound = {
  a: 'whatever',
  b: 'whatever',
};

const doSomething = <T extends ParamOneType>(
  paramOne: T,
  paramTwo: PossibleValues[T],
): void => {
  switch (paramOne) {
    case ParamOneType.Round: {
      // Type 'PossibleValues[T]' cannot be used to index type '{ a: string; b: string; }'
      const b = indexRound[paramTwo];
    }
  }
};

Why do I get the error Type 'PossibleValues[T]' cannot be used to index type '{ a: string; b: string; }' here? Intellisense does seem to pick up the function signature correctly, for example my autocomplete in VSCode shows:

const doSomething: <ParamOneType.Round>(paramOne: ParamOneType.Round, paramTwo: "a" | "b") => void

when calling the function as doSomething(ParamOneType.Round, 'a')

CodePudding user response:

For this example, where you do a switch on paramOne, I'd stay away from generics, which cannot be narrowed by control flow analysis. It's always possible that T could be the full ParamOneType type, at which point someone could call doSomething() with parameters you don't expect, like doSomething( Math.random()<0.99 ? ParamOneType.Round : ParamOneType.Square, "c"), which has a 99% chance of being bad. This makes narrowing inside generic functions tricky. It's a known issue, and there are multiple feature requests for some way to improve it. See microsoft/TypeScript#27808 or microsoft/TypeScript#33014 for more information.

Instead, you can give doSomething a rest parameter whose type is a discriminated union of rest tuple types, and immediately destructure into the two variables paramOne and paramTwo.

type DoSomethingParams =
 [paramOne: ParamOneType.Round, paramTwo: "a" | "b"] | 
 [paramOne: ParamOneType.Square, paramTwo: "c" | "d"]

const doSomething = ([paramOne, paramTwo]: DoSomethingParams): void => { }

You can see that this behaves as desired from the caller's side:

doSomething(ParamOneType.Round, "a") // okay
doSomething(ParamOneType.Round, "c") // error
doSomething(ParamOneType.Square, "c") // okay
doSomething(ParamOneType.Square, "b") // error

And since TypeScript 4.6 and above supports control flow analysis for destructured discriminated unions, you can switch on paramOne in the implementation and the compiler will automatically narrow paramTwo accordingly:

const doSomething = (...[paramOne, paramTwo]: DoSomethingParams): void => {
  switch (paramOne) {
    case ParamOneType.Round: {
      const b = indexRound[paramTwo]; // okay
    }
  }
};

Also note that you can generate the above DoSomethingParams type programmatically from PossibleValues, as follows:

type DoSomethingParams = { [T in ParamOneType]:
  [paramOne: T, paramTwo: PossibleValues[T]]
}[ParamOneType]

This is called a distributive object type (as coined in ms/TS#47109) where you immediately index into a mapped type to get a union of [paramOne: T, paramTwo: PossibleValues[T]] for every T in PossibleValues.

Playground link to code

  • Related