Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Generic with union type throwing not assignable error

I have a reducer that has an action creator that can be an array of two different types of object, each has their own interface. However, I am getting this error

Type '(A | B)[]' is not assignable to type 'B[]'.
  Type 'A | B' is not assignable to type 'B'.
    Property 'productionId' is missing in type 'A' but required in type 'B'

I suspect this error is due to both interface having similar values, except B has one extra value than A?

Here is the typescript playground

Here is the full reproducible code

interface A {
  id: number;
  name: string;
}

interface B {
  id: number;
  productionId: number;
  name: string;
}

interface IAction<Data> {
  type: string;
  data: Data;
}

interface ISelectionOptionsReducerState {
  a: A[];
  b: B[];
}

let initialState: ISelectionOptionsReducerState = {
  a: [],
  b: []
};

type TAction = IAction<Array<A | B>>;
type TAction = IAction<A[] | B[]>; <= this didn't work either

type TReducer = (
  state: ISelectionOptionsReducerState,
  action: TAction
) => ISelectionOptionsReducerState;

const selectionOptionsReducer: TReducer = (
  state: ISelectionOptionsReducerState = initialState,
  action: TAction
): ISelectionOptionsReducerState => {
  Object.freeze(state);

  let newState: ISelectionOptionsReducerState = state;

  switch (action.type) {
    case '1':
      newState.a = action.data;
      break;
    case '2':
      newState.b = action.data; <= error happen here
      break;
    default:
      return state;
  }

  return newState;
};
like image 410
davidhu Avatar asked Jun 10 '19 19:06

davidhu


2 Answers

Couple things:

First,

Array<A | B>
(A | B)[]

are all identical.

Second, the reason why A is assignable to both is because all properties of A are also in B.

Third, don't mutate state. Reassigning it isn't enough.

> const x = {}
undefined
> const y = x
undefined
> y.a = 1
1
> x
{ a: 1 }

You can splat into a new object: let newState = { ...state } - that's generally sufficient.

Ok. You can't assign a value of type A | B to something that's type B. You've used something else (type) to signal a different value, but TS can't know about that unless you tell it. There are a number of different ways you can do it.

First, assert:

newState.b = action.data as B[];

This is effectively telling TS to bugger off. More often than not, this is fine... If you're doing something really questionable, TS will make you assert to unknown first. That's not the case, here, though.

but there are better ways to do it.

Slightly better: type guards

This requires refactoring out the switch:

function isA(x: any): x is IAction<Array<A>> {
  return x.type === '1'
}

function isB(x: any): x is IAction<Array<B>> {
  return x.type === '2'
}

...

if (isA(action)) {
  newState.a = action.data;
} else if (isB(action)) {
  newState.b = action.data;
}

(note: I can't actually get this to work... the code is right, I'm just getting type never for action after the first check - not sure what's going on here)

Finally, let TypeScript do the resolution for you via duck-typing.

The tl;dr of that is that if you have a property within an object that correlates with a type, TS can automatically pick up types if that property is sufficiently unique.

like image 126
Tyler Sebastian Avatar answered Nov 10 '22 19:11

Tyler Sebastian


I suspect this error is due to both interface having similar values, except B has one extra value than A?

Yes, you can assign B to A, but not A to B.

You need type guard:

function isA(data: A | B): data is A {
  return typeof (data as B).productionId === 'undefined'
}

function isB(data: A | B): data is B {
  return typeof (data as B).productionId === 'string'
}

... 

case '1':
  newState.a = action.data.filter(isA);
  break;
case '2':
  newState.b = action.data.filter(isB);
  break;

Edit: (I can't write comments)

@Tyler Sebastian

Array<A | B>
(A | B)[]

are identical, but A[] | B[] is diffrent

like image 44
aquz Avatar answered Nov 10 '22 19:11

aquz