TypeScript: Contextual type can't be provided to a mapped type intersected with an object type

Bug Report

๐Ÿ”Ž Search Terms

intersection, mapped type, contextual type

๐Ÿ•— Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ

โฏ Playground Link

Playground

๐Ÿ’ป Code

type Action<TEvent extends { type: string }> = (ev: TEvent) => void;

interface MachineConfig<TEvent extends { type: string }> {
  schema: {
    events: TEvent;
  };
  on?: {
    [K in TEvent["type"]]?: Action<TEvent extends { type: K } ? TEvent : never>;
  } & {
    "*"?: Action<TEvent>;
  };
}

declare function createMachine<TEvent extends { type: string }>(
  config: MachineConfig<TEvent>
): void;

createMachine({
  schema: {
    events: {} as { type: "FOO" } | { type: "BAR" },
  },
  on: {
    FOO: (ev) => {
      ev.type; // should be 'FOO', but `ev` is typed implicitly as `any`
    },
  },
});

๐Ÿ™ Actual behavior

An implicit any pop-ups when the contextual type could be, somewhat easily, provided.

๐Ÿ™‚ Expected behavior

This should just work ๐Ÿ˜œ I know a workaround for this issue - the workaround is to use a single mapped type instead of an intersection and just โ€œdispatchโ€ to the correct value in the template~ part of the mapped type, like here. However, this is way less ergonomic than an intersection AND the mapped type is no longer homomorphic which could matter for some cases (well, the original mapped type here is not homomorphic either, but it could be)

I already have a draft PR open to fix this issue, here. I only need some help with the stuff mentioned in the comment here

About this issue

  • Original URL
  • State: open
  • Created 2 years ago
  • Comments: 16 (7 by maintainers)

Most upvoted comments

Lemme try an explanation ๐Ÿ˜„

Weโ€™re letting the user provide an object in the form

{
  reducer(state, action: PayloadAction<something>) {},
  prepare(arg) { return actionPayload } 
}

now, we want the return type of actionPayload to be the same as something up in PayloadAction. But we cannot just add a generic <T> somewhere to make sure thatโ€™s the case - because this object is not the only object being passed in, but one of many objects inside a config object:

{
  reducers: {
    foo: { reducer: ... ,  prepare: ... },
    bar: { reducer: ... ,  prepare: ... },
  }
}

Now, TS has no syntax to allow for different ActionPayload types for foo and bar while having those internally consistent (foo only has FooActionPayload both on reducer and prepare and bar only has BarActionPayload both on reducer and prepare).

So what we do is that we let TypeScript infer this whole configuration object including all reducers (the โ€œfirst passโ€) and then, when we have that, we use that ConfigObject to restrict it against itself (the โ€œsecond passโ€) - ConfigObject extends Validated<ConfigObject> where Validated is a generic that infers FooActionPayload from ReturnType<ConfigObject['reducers']['foo']['prepare']> and makes sure that the second argument to reducer matches that type.

Itโ€™s amazing that we could do something like that in the first place, but itโ€™s also pretty necessary here to make the api work in a type-safe manner.

Good point, here is a test case: Playground link

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

{
  createSlice({
    name: "test",
    initialState: 0,
    reducers: {
      // normal reducer
      test(state, action: PayloadAction<number>) {
        return state + action.payload
      }
    }
  })
}


{
  createSlice({
    name: "test",
    initialState: 0,
    reducers: {
      // reducer with prepare
      test: {
        reducer(state, action: PayloadAction<number>) {
          return state + action.payload
        },
        prepare(arg: number) {
          return { payload: arg * 2 }
        }
      }
    }
  })
}


{
  createSlice({
    name: "test",
    initialState: 0,
    reducers: {
      // reducer with incorrect prepare
      test: {
        // @ts-expect-error action payload needs to be a number, as returned by `prepare`
        reducer(state, action: PayloadAction<string>) {
          return state + action.payload
        },
        prepare(arg: number) {
          return { payload: arg * 2 }
        }
      }
    }
  })
}


{
  createSlice({
    name: "test",
    initialState: 0,
    reducers: {
      // normal reducer
      test1(state, action: PayloadAction<number>) {
        return state + action.payload
      },
      // reducer with prepare
      test2: {
        reducer(state, action: PayloadAction<number>) {
          return state + action.payload
        },
        prepare(arg: number) {
          return { payload: arg * 2 }
        }
      },
      // reducer with incorrect prepare
      test3: {
        // @ts-expect-error action payload needs to be a number, as returned by `prepare`
        reducer(state, action: PayloadAction<string>) {
          return state + action.payload
        },
        prepare(arg: number) {
          return { payload: arg * 2 }
        }
      },
      // another reducer with incorrect prepare and different payload type
      test4: {
        // @ts-expect-error action payload needs to be { value: number }, as returned by `prepare`
        reducer(state, action: PayloadAction<{value: string}>) {
          return state + action.payload.value
        },
        prepare(arg: number) {
          return { payload: { value: arg * 2 }}
        }
      }
    }
  })
}