TypeScript intersection with generic type, and using Partial

Viewed 295

I have a pretty tricky TypeScript issue with generics and partial.

A playground for this issue is here

I will explain the issue using React terminology, although it has nothing to do with React directly.

There's a custom React hook, that basically creates a reducer, using useReducer, and it contains some common properties, along with a generic data property.

Let's pretend this is the hook:

interface IState<T> {
    some: string;
    data: T;
}

type Action<T> = { type: 'UPDATE_STATE'; payload: Partial<T> };

const foo = <T>(initalState: T) => {
    const state: IState<T> = { some: 'foo', data: initalState };
    const dispatch = (action: Action<T>) => {
        // do something
    }

    return {state, dispatch};
};

In addition, there's another custom hook, that actually extends the first one by adding additional common properties to the state reducer:

interface MyBar {
    extraData: string;
}

const bar = <T>(initialState: T) => {
    const {state, dispatch} = foo<T & MyBar>({ ...initialState, extraData: 'some data' });
    
    return { state, dispatch };
}

Now, a typical usage would be:

const baz = () => {
    const { state, dispatch } = bar<IMyState>({ name: 'mor' });
    console.log(state, dispatch)
}

So in baz, my state object will be:

{
 "extraData": "some data",
 "some": "foo",
 "data": {
  "name": "mor"
 }
}

So far so good. Now I want to do the following change:

const bar = <T>(initialState: T) => {
    const {state, dispatch} = foo<T & MyBar>({ ...initialState, extraData: 'some data' });
    
    **dispatch({ type: 'UPDATE_STATE', payload: { extraData: 'changed' } });**
    return { state, dispatch };
}

And I get:

Type '{ extraData: "changed"; }' is not assignable to type 'Partial<T & MyBar>'.(2322)
input.ts(7, 42): The expected type comes from property 'payload' which is declared here on type 'Action<T & MyBar>'

I am not sure why that happens, since bar passes the generic type alongside with the extra data, I'd expect that foo would be able to allow it.

I'd appreciate any advice!

5 Answers

Look at below code:

const {state, dispatch} = foo<T & MyBar>({ ...initialState, extraData: 'some data' });
dispatch({ type: 'UPDATE_STATE', payload: { extraData: 'changed' } });

You must passing Action<T & MyBar> to dispatch but you sent only MyBar type! So you have two options:

Change & to | if you want to send only extraData: 'changed' as your parameter or add ...initialState as a parameter too like this:

dispatch({ type: 'UPDATE_STATE', payload: { ...initialState, extraData: 'changed' } }); 

PlaygroundLink

Poking at the underlying types reveals a slightly more verbose type error:

const bar = <T>(initialState: T) => {
    const {state, dispatch} = foo<T & MyBar>({ ...initialState, extraData: 'some data' });
    
    type Payload = Parameters<typeof dispatch>[0] extends Action<infer T> ? T : never
    const payload: Payload = { extraData: 'changed' };
    //    ^^^^^^^
}
Type '{ extraData: string; }' is not assignable to type 'T & MyBar'.
  Type '{ extraData: string; }' is not assignable to type 'T'.
    'T' could be instantiated with an arbitrary type which could be unrelated to '{ extraData: string; }'.(2322)

What I think is happening is that typescript is trying to warn about a possibility to make nonsensical type combinations, such as this:

bar(10).state.data.toFixed()

state now has a type IState<number & MyBar>, so the toFixed() call compiles, but since data is actually an object it throws an exception at runtime.

Unfortunately this doesn't really answer your question since I was unable to find any way to constrain T to make it compile.

TypeScript is unable to make any assumptions about assignability in this case because T is not infered to any type. It is like a pandora box.

Consider this example:

interface IState<T> {
  some: string;
  data: T;
}

type Action<T> = { type: 'UPDATE_STATE'; payload: Partial<T> };

const foo = <T,>(initalState: T) => {
  const state = { some: 'foo', data: initalState };
  const dispatch = (action: Action<T>) => { }

  return { state, dispatch };
};

interface MyBar {
  extraData: string;
}

const bar = <T extends Record<string, unknown>,>(initialState: T) => {
  const { state, dispatch } = foo({...initialState, extraData: 'some data' });
  let x: Partial<T>
  let y: { extraData: string; }

  x = y // error
  y = x // error


  // TS is unable to make any assumptions about T
  dispatch({ type: 'UPDATE_STATE', payload: { extraData: 's' } })
  return { state, dispatch };
}

const baz = () => {
  const { state, dispatch } = bar({ name: 'mor', extraData: 's' });
  dispatch({ type: 'UPDATE_STATE', payload: { extraData: 's' } })
  console.log(state, dispatch)
}

Playground

I'm talking about this part of the code:

  let x: Partial<T>
  let y: { extraData: string; }

  x = y // error
  y = x // error

x an y are not assignable to each other. What if T is this object { name: string } - then this behavior is unsafe. Because these object have nothing in common.

I know, that when you call bar TS is able to infer the type but remember we are not in calling phase..

Further more, the main problem is in static extraData property. It should be also parametrized with generic parameter.

Consider this example:

interface IState<T> {
  some: string;
  data: T;
}

type Action<T> = { type: 'UPDATE_STATE'; payload: Partial<T> };

const foo = <T,>(initalState: T) => {
  const state = { some: 'foo', data: initalState };
  const dispatch = (action: Action<T>) => { }
  return { state, dispatch };
};

interface MyBar {
  extraData: string;
}

const bar = <T, U>(initialState: T, extraData: U) => {
  const { state, dispatch } = foo({ ...initialState, ...extraData });

  dispatch({ type: 'UPDATE_STATE', payload: { ...extraData } })
  return { state, dispatch };
}

const baz = () => {
  const { state, dispatch } = bar({ name: 'mor' }, { extraData: 's' });
  dispatch({ type: 'UPDATE_STATE', payload: { extraData: 's' } })
  console.log(state, dispatch)
}

Playground

As mentioned in this issue and also above it is a typescript design limitation

So the only way I see is to use type assertion, something like this

const bar = <T>(initialState: T) => {
    const {state, dispatch} = foo<T & MyBar>({ ...initialState, extraData: 'some data' });
    const createPayload = (state: Partial<MyBar>) => state as T & MyBar;
    
    dispatch({ type: 'UPDATE_STATE', payload: createPayload({ extraData: 'changed' }) });
    return { state, dispatch };
}

First of all, the spreading notation does not result in a type that is a simple union of the two (or more) source types.

type SpreadTwoObj<O1, O2> = Omit<O1, keyof O2> & O2

type A = { a: number, b: string };
type B = { b: number, c: string };
declare const a: A;
declare const b: B;

type C1 = A & B; // just a union
type C2 = SpreadTwoObj<A, B>;
const c1: C1 = { ...a, ...b }; // error
const c2: C2 = { ...a, ...b };

Then imagine that the first type is not an object literal.

type U1 = SpreadTwoObj<{ [K in string]: number }, { a: string }>;
const u1: U1 = { a: 'aa' } // error

It is because Omit<{ [K: string]: number; }, "a"> does not really eliminate the key "a". (Exclude<string, 'a'> is still string). This is the limitation of TypeScript. Compare with example above:

type U2 = SpreadTwoObj<{ [K in 'a']: number }, { a: string }>;
const u2: U2 = { a: 'aa' } // works

The generic function <T>(initialState: T) => any that you have allows having such an ambiguous object type for the argument. Nothing can prohibit it. In your case the function foo({ ...initialState, extraData: 'some data' }) returns dispatch of type (action: Action<T & { extraData: string }>) => void. Let T = { [K in string]: number }. What is the type T & { extraData: string } then? It is very unsound type:

type T = { [K in string]: number };
type D = T & { extraData: string };

const d1: D = { extraData: 'changed' }; // Type 'string' is not assignable to type 'number'
const d2: D = { extraData: 1 }; // Type 'number' is not assignable to type 'string'

That is why your example will never work unless hard code type assuming. I would do something like this although it looks mess:

const bar = <T>(initialState: T) => {
    const { dispatch } = foo({ ...initialState, extraData: 'some data' });

    (dispatch as (action: Action<{
        extraData: string;
    }>) => void)({ type: 'UPDATE_STATE', payload: { extraData: 'changed' } });
    return { dispatch };
}

(I rid off state from the example because it is irrelevant.)

Supporting playground

Related