Ivan Kleshnin
Ivan Kleshnin

Reputation: 1844

TypeScript & React: reusable generic actions / components

I use TypeScript with React and useReducer and I want to define reducer Actions in a type-safe way.

The simplest approximation of Action is:

type Action = {name : string, payload : any}

The more precise version requires union types:

type Action =
  | {name : "setColumns", payload: string[]}
  | {name : "toggleColumn", payload: string}
  ...

So far so good. Then I want to define components which depend on Action or rather on its derivative React.Dispatch<Action>. There are two ways to do that:

  1. Accept (multiple) generics
  2. Define wider types

The approach 1) is more type-safe in theory yet much more verbose and complex in practice. The approach 2) can be a good balance between safety and complexity.

Example of Pager component props in both styles:

// 1)
export type PagerProps1 <Page extends number, Limit extends number> = {
  page : Page // -- narrower types
  limit : Limit
  pagesTotal : number
}

// 2)
export type PagerProps2 = {
  page : number // -- wider types
  limit : number
  pagesTotal : number
}

^ now it's possible to define Pager2 and move it to the library with no dependency on Page and Limit which are app-specific. And without generics. That was the intro to provide necessary context.

The problem comes with React.Dispatch. Here's the test-case that imitates reusal of generic dispatch in place where more precise version is present:

type Action =
  | {name : "setColumn"}
  | {name : "toggleColumn"}

type OpaqueAction1 = {name : any}    // will work
type OpaqueAction2 = {name : string} // will not work

type Dispatch = React.Dispatch<Action>
type OpaqueDispatch1 = React.Dispatch<OpaqueAction1> // will work
type OpaqueDispatch2 = React.Dispatch<OpaqueAction2> // will not work

export const DemoComponent = () => {
  const dispatch = React.useReducer(() => null, null)[1]
  const d0 : Dispatch = dispatch
  const d1 : OpaqueDispatch1 = d0 // ok
  const d2 : OpaqueDispatch2 = d0 // type error
}

The error is the following:

TS2322: Type 'Dispatch<Action>' is not assignable to type 'Dispatch<OpaqueAction2>'.   
Type 'OpaqueAction2' is not assignable to type 'Action'.     
Type 'OpaqueAction2' is not assignable to type '{ name: "toggleColumn"; }'.       
Types of property 'name' are incompatible.         
Type 'string' is not assignable to type '"toggleColumn"'.

^ but in the code above we actually assign "toggleColumn" to string. Something is wrong.

Here is the sandbox: https://codesandbox.io/s/crazy-butterfly-yldoq?file=/src/App.tsx:504-544

Upvotes: 0

Views: 472

Answers (1)

Patrick Roberts
Patrick Roberts

Reputation: 51886

You're not assigning "toggleColumn" to string, you're assigning Dispatch<Action> to Dispatch<OpaqueAction2>.

The problem is that Dispatch<Action> is a function that can only handle a parameter with a name property "toggleColumn", while Dispatch<OpaqueAction2> is a function that can handle a parameter with a name property of any string type. The assignment implies that Dispatch<Action> should be able to handle any string type as well, but it can't.

A function (...args: T) => R is assignable to (...args: U) => R if and only if U is assignable to T. This is why the first two lines of your error message reverse the order of the types:

Type 'Dispatch<Action>' is not assignable to type 'Dispatch<OpaqueAction2>'.   
Type 'OpaqueAction2' is not assignable to type 'Action'.

Upvotes: 2

Related Questions