2
//Type declaration:

interface TickListFilter {
   type: "tickList";
   value: string;
}

interface ColorFilter {
   type: "color"
   value: ColorValueType
}

type Filter = TickListFilter | ColorFilter;



...
onValueChange = (filter: Filter, newValue: Filter["value"]) => {
        if (filter.type === "tickList") {
            // variable 'filter' has TickListFilter type (OK)
            // variable 'filter.value' has string type (OK)
            // variable newValue has ColorValueType | string. I know why, let's fix it!
        }
...

onValueChange = <T extends Filter>(filter: T, newValue: T["value"]) => {
        if (filter.type === "tickList") {
            // variable 'filter' has Filter type (wrong... i need tick list)
            // variable 'filter.value' has string | ColorValueType type (wrong, it should be string)
            // variable newValue has ColorValueType | string.
        }

How I can fix that? I know why it happens, because TS cant discriminate union by generic type. But is there any workaround to work as I described?

I want to use similar structure as switch-case (not if-else only)

Kakaku
  • 48
  • 7

2 Answers2

3

Ah, welcome to microsoft/TypeScript#13995 and microsoft/TypeScript#24085. Currently the compiler does not use control flow analysis to narrow generic type parameters or values of types dependent on generic type parameters. Technically it would be wrong to narrow the type parameters themselves, since someone could come along and call the generic version this way:

const filter: Filter = Math.random() < 0.5 ?
  { type: "tickList", value: "str" } : { type: "color", value: colorValue };
const newValue: string | ColorValueType = Math.random() < 0.5 ? "str" : colorValue;
onValueChange(filter, newValue); // no compiler error
// const onValueChange: <Filter>(filter: Filter, newValue: string | ColorValueType) => void

The compiler will infer Filter for T, which is "correct" according to the generic signature, but it leads to the same problem as your non-generic version. Inside the function it may be that newValue doesn't match filter.


There have been a number of suggestions and discussions inside the above GitHub issue on ways to deal with this problem. If something like microsoft/TypeScript#27808, you could say something like T extends-exactly-one-member--of Filter meaning that T could be TickListFilter or ColorFilter but it could not be Filter. But right now there's no way to say this.


For now the easiest way to proceed (assuming you don't want to actually check inside the implementation of your function that filter and newValue match each other) is going to involve type assertions or something like them. You need to loosen type checking enough to allow the code, and just expect/hope that nobody will call your function the way I did above.

Here's one way using type assertions:

const onValueChange = <T extends Filter>(filter: T, newValue: T["value"]) => {
  const f: Filter = filter; // widen to concrete type
  if (filter.type === "tickList") {
    const v = newValue as TickListFilter["value"]; // technically unsafe narrowing
    f.value = v; // okay
  } else {
    const v = newValue as ColorFilter["value"]; // technically unsafe narrowing
    f.value = v; // okay
  }
}

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
0

If you want a manual solution, you can resort to writing your own type guards:

// Either like this, simple but a lot of typing
const isColorFilter = (value: Filter): value is ColorFilter => value.type === 'color';

// Or write a small type guard generator if there are lots of these types
const createFilterTypeGuard = <T extends Filter>(type: T['type']) => (value: Filter): value is T => value.type === type;

// And then
const isColorFilter = createFilterTypeGuard<ColorFilter>('color');

Then in your code you can use them like so:

  if (isColorFilter(filter)) {
    // filter is ColorFilter
  }

However, you are going to run into issues with your newValue. Since the type guards will only narrow the type of filter, newValue will stay un-narrowed:

onValueChange = <T extends Filter>(filter: T, newValue: T["value"]) => {
  // OK, filter is T and newValue is T['value']
  filter.value = newValue;

  if (isColorFilter(filter)) {
    // Not OK, filter is ColorFilter now but newValue is still Filter['value']
    filter.value = newValue;
  }
}

You will still get correct validation when calling onValueChange, you will just need to do some type casting inside the function body :(

Ján Jakub Naništa
  • 1,880
  • 11
  • 12