1

I have the follow components and function:

interface ILabel<T> {
  readonly label: string;
  readonly key: T
}

interface IProps<T> {
  readonly labels: Array<ILabel<T>>;
  readonly defaultValue: T;
  readonly onChange: (state: ILabel<T>) => void;
}

const testFunc = <T extends string | number>(labels: IProps<T>) => labels

My intention is to be able to mix different types for the keys, so some could be a string, some could be a number, and some could be a boolean. My intention is to infer the type from the labels and apply that to other props, such as defaultKey or onSubmit that benefit from literal types. Otherwise, type guarding is required to ensure only the keys are acceptable values to other functions, which places responsibility on the engineer for every use case. I would like to avoid this.

I am perfectly able to call the function when all the keys are of the same type.

testFunc({
  labels: [{
    label: 'whatever',
    key: 'a',
  }, {
    label: 'whatever',
    key: 'b',
  }, {
    label: 'whatever',
    key: 'c',
  }],
  defaultValue: 'a',
  onChange: (state) => {}
}) // Correctly assigns type IProps<'a' | 'b' | 'c'>

However, whenever I try mixing types, typescript thinks that T is a constant of the first index, and throws an error for all the other values. Example:

testFunc({
  labels: [{
    label: 'whatever',
    key: 'a',
  }, {
    label: 'whatever',
    key: 'b',
  }, {
    label: 'whatever',
    key: 2,
  }],
  defaultValue: 'c',
  onChange: (state) => {}
}) // Errors because it thinks the type is IProps<'a'>
Type '"b"' is not assignable to type '"a"'.ts(2322)
TabBar.types.ts(79, 12): The expected type comes from property 'key' which is declared here on type 'ILabel<"a">'

There is a way to mix them by using primitives. For example, the following works:

enum TestEnum {
  ONE = 'one',
  TWO = 'two',
  THREE = 3,
}

enum TestEnum2 {
  FOUR = 'four',
  FIVE = 'five',
  SIX = 6,
}

testFunc({
  labels: [{
    label: 'whatever',
    key: TestEnum.ONE,
  }, {
    label: 'whatever',
    key: TestEnum.TWO,
  }, {
    label: 'whatever',
    key: TestEnum.THREE,
  }],
  defaultValue: TestEnum.TWO,
  onChange: (state) => {}
}) // Correctly works as IProps<TestEnum>

However, all keys must be of the type TestEnum. Mixing with primitive literals or other enum values also results in the same error.

// Mixing different enums
testFunc({
  labels: [{
    label: 'whatever',
    key: TestEnum.ONE,
  }, {
    label: 'whatever',
    key: TestEnum.TWO,
  }, {
    label: 'whatever',
    key: TestEnum2.FOUR,
  }],
  defaultValue: TestEnum.TWO,
  onChange: (state) => {}
}) // Errors because it infers as IProps<TestEnum.ONE>

// Mixing enum and literal
testFunc({
  labels: [{
    label: 'whatever',
    key: TestEnum.ONE,
  }, {
    label: 'whatever',
    key: 'two',
  }],
  defaultValue: 'two',
  onChange: (state) => {}
}) // Errors because it infers as IProps<TestEnum.ONE>

What explanation is there for typescript to be confused? Or is there a logical explanation?

I understand the use case of mixing string and number literals is very well, but it is likely an engineer would try mixing different enums, or enums with literals and be confused with this ambiguous error.

Playground Link

  • 1
    Welcome to Stack Overflow! `enum` can't have `true` in it. Can you make sure the code is a [mre] we can copy and paste into our own IDEs and see the problem you're having without being distracted by unrelated problems? – jcalz Aug 30 '23 at 01:00
  • 1
    (cont'd) TS doesn't synthesize unions of different widened types, since often people *want* an error instead (e.g., `const f = (x: T, y: T) => {}` people want to reject `f("a", 1)`). See [this SO q/a](//stackoverflow.com/q/39905523/2887218). There's a feature request at [ms/TS#44312](//github.com/microsoft/TypeScript/issues/44312) to allow unions, but it's not implemented. My workaround would be to infer a full array type instead of its elements, as shown [here](//tsplay.dev/mLJOaw). Does that fully address the question? If do I'll write up an answer explaining; if not, what am I missing? – jcalz Aug 30 '23 at 01:12
  • ① The code here still is not a [mre] since `...otherProps` is syntactically invalid pseudocode; maybe you can make it a comment instead? ② Could you please acknowledge my previous comment so I can know how best to proceed here? – jcalz Aug 30 '23 at 13:08
  • Yes, it seems like it is related to the issue you discuss. I have edited the post and linked the playground url at the bottom. – Hantoa Tenwhij Aug 30 '23 at 15:18
  • Please make sure your playground link matches your plaintext code. – jcalz Aug 30 '23 at 15:23
  • To get around the issue, what if we try inferring the type from the return instead. As in, what if the function could infer the type parameter from the return type instead? For example, what if in the function we have `const keys = labels.map(label => label.key)` and `type KeysType = (typeof keys)[number]`, which can be used to create a fake return type, `ActualReturnType`. I tried this and unfortunately it still inferred `KeysType` to be `T`. Is there a way to still typescript to work backwards from the return type? – Hantoa Tenwhij Aug 30 '23 at 15:45
  • @jcalz Edited to match playground – Hantoa Tenwhij Aug 30 '23 at 15:49
  • So you *want* `defaultValue: 'c',` for `"a" | "b" | 2`? Why? – jcalz Aug 30 '23 at 15:49

1 Answers1

0

TypeScript tends not to synthesize new union types that cross widened literal boundaries (so "a" | "b" from "a" and "b" is fine, since both widen to string, but "a" | 1 from "a" and 1 is not fine, since one widens to string and the other widens to number). This is because people often want to flag code where this would happen as an error. See Why isn't the type argument inferred as a union type?. The canonical example is something like

declare function f<T extends string | number>(x: T, y: T): void;
f("a", "b") // okay
f(1, 2); // okay
f("a", 2); // error

While some would argue that f("a", 2) should succeed, more people seem to feel it should fail, and so it does.

There is an open feature request at microsoft/TypeScript#44312 to allow type parameters to be declared with some modifier expressing the desire to synthesize unions where possible. If that ever gets implemented maybe you could write something like declare function f<allowunion T extends string | number>(x: T, y: T): void or const testFunc = <allowunion T extends string | number>(labels: IProps<T>) => labels, but for now it's not part of the language and you'll need to work around it.


A common workaround with heterogeneous arrays is to have the type parameter correspond to the array type itself (which is a single type) and not the array element type (which is likely to be a union). That requires rephrasing everything:

interface MyProps<T extends Array<string | number>> {
  readonly labels: { [I in keyof T]: ILabel<T[I]> };
  readonly defaultValue: T[number];
  readonly onChange: (state: ILabel<T[number]>) => void;
}

const testFunc = <T extends Array<string | number>>(
  labels: MyProps<T>
): IProps<T[number]> => labels;

So now the type parameter T corresponds to the array of strings/numbers inside the elements' key properties, and when you call testFunc the compiler infers T from the mapped array type of the labels property of MyProps<T>. And to refer to the element type we just index into the array type with number.


Let's test it out:

const v = testFunc({
  labels: [
    { label: 'whatever', key: 'a', },
    { label: 'whatever', key: 'b', },
    { label: 'whatever', key: 'c', }
  ],
  defaultValue: 'a',
  onChange: (state) => { }
}); // okay
// const v: IProps<"a" | "b" | "c">

const w = testFunc({
  labels: [
    { label: 'whatever', key: 'a', },
    { label: 'whatever', key: 'b', },
    { label: 'whatever', key: 2, }
  ],
  defaultValue: 2,
  onChange: (state) => { }
}); // okay
// const w: IProps<"a" | "b" | 2>
    
const x = testFunc({
  labels: [
    { label: 'whatever', key: TestEnum.ONE, },
    { label: 'whatever', key: TestEnum.TWO, },
    { label: 'whatever', key: TestEnum.THREE, }
  ],
  defaultValue: TestEnum.TWO,
  onChange: (state) => { }
}); // okay
// const x: IProps<TestEnum>

const y = testFunc({
  labels: [
    { label: 'whatever', key: TestEnum.ONE, },
    { label: 'whatever', key: TestEnum.TWO, },
    { label: 'whatever', key: TestEnum2.FOUR, }
  ],
  defaultValue: TestEnum.TWO,
  onChange: (state) => { }
}); // okay
// const y: IProps<TestEnum.ONE | TestEnum.TWO | TestEnum2.FOUR>

const z = testFunc({
  labels: [
    { label: 'whatever', key: TestEnum.ONE, },
    { label: 'whatever', key: 'two', }
  ],
  defaultValue: 'two',
  onChange: (state) => { }
}); // okay
// const z: IProps<"two" | TestEnum.ONE>

This all works as desired.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360