Why can't Typescript infer the type of a nested generic function?

Viewed 302

Typescript types the state argument to the applyReducer call below as unknown. It works if I explicitly specify the type with applyReducer<State>, but why is this necessary? It seems pretty clear the type should be State.

(Typescript v4.1.2)

reducerFlow.ts:

type UnaryReducer<S> = (state: S) => S

export const applyReducer = <S>(reducer: UnaryReducer<S>) =>
  (state: S) => reducer(state)

foo.ts

interface State { a: number, b: number }
const initialState: State = { a: 0, b: 0 }

// Why is the type of state unknown?
// Typescript can't infer that it is State?
const foo = applyReducer(
    state => ({ ...state, b: state.b + 1 })
)(initialState)

TS Playground

2 Answers

UPDATE Here is my answer:

Example:


type UnaryReducer = <S>(state: S) => S

interface ApplyReducer {
  <T extends UnaryReducer>(reducer: T): <S,>(state: ReturnType<T> & S) => ReturnType<T> & S;
}

export const applyReducer: ApplyReducer = (reducer) =>
  (state) => reducer(state)


interface State { a: number, b: number }
const initialState: State = { a: 0, b: 0 }


const bar = applyReducer(
  state => ({ ...state, b: 2, })
)(initialState)
bar // {b: number; } & State

const bar2 = applyReducer(
  state => ({ ...state, b: '2', }) 
)(initialState) // Error: b is not a string

const bar3 = applyReducer(
  state => ({ ...state, b: 2, c:'2' }) 
)(initialState) // Error: Property 'c' is missing in type 'State'
 
const bar4 = applyReducer(
  state => ({ ...state }) 
)(initialState) // Ok

const bar5 = applyReducer(
  state => ({ a: 0, b: 0 }) // Error: you should always return object wich is extended by State
)(initialState)

const bar6 = applyReducer(
  state => ({...state, a: 0, b: 0 }) // Ok
)(initialState)

We should define generic parameter directly for arrow function

type UnaryReducer = <S>(state: S) => S

We should somehow bind initialState argument and ReturnType of reducer

interface ApplyReducer {
  <T extends UnaryReducer>(reducer: T): <S,>(state: ReturnType<T> & S) => ReturnType<T> & S;
}

It is mean that state argument of reducer (callback) should be always a part of return type.

That's mean, that if you will try to:

state => ({ a:0, b: 2, }) 

it is not gonna work, but I think there is not sence to do it

UPDATE I think it is impossible to do. Consider this example:

const foo = applyReducer(
  state => ({ ...state, b: state.b + 1 })
)

const result = foo((initialState))

You are able to infer initialState only during foo call. NO way TS will know upfront the argument type during foo declaration.

TS infers arguments from left to right.

To answer your question:

It works if I explicitly specify the type with applyReducer, but why is this necessary?

The problem is with the fact that this is a curried function, i.e. (x) => (y) => z. Because the type parameter S is on the first function it will 'instantiate' (get a concrete type) as soon as you call that function. You can see that in action by looking at the type of fn below:

const fn = applyReducer((state) => ({ ...state, b: 2 }))
//                                    ^^^^^^^^ error (because you can't spread 'unknown')
//
// const fn: (state: unknown) => unknown

Because in the argument (state) => ({ ...state, b: 2 }) there is no information available about what S should become, typescript defaults to unknown. So S is unknown now, and regardless of what you call fn with afterwards, it will stay unknown.

One way to fix this is to - as you mentioned - explicitly provide the type argument:

const fna = applyReducer<State>((state) => ({ ...state, b: 2 }))

And another way is to give typescript some information from where it can infer the type for S, for example a type constraint on the state parameter:

const fnb = applyReducer((state: State) => ({ ...state, b: 2 }))
Related