oyalhi
oyalhi

Reputation: 3984

Why is TypeScript failing checking the return type of a function

I have a function which should return NetworkState; however, as can be seen in the code, the function in most cases not returning the correct type but TypeScript gives no error. What am I missing?

SECOND UPDATE: Thanks everyone for their input. I've copied and simplified the code as much as I can, the complete code is copy and pasteable to TypeScript playground. Unfortunately I have to include ALL types to reproduce the problem, thus the code is a little longer than I hoped. Please look at the last line where I think there should be an error (because of the return type) but there is none.

Any help is much appreciated, thank you.

UPDATE and clarification:

Example 1:

const someFunction = (): string => 45

the above function's return type is string but we are returning a number, thus TypeScript gives an error: Type '45' is not assignable to type 'string'

Example 2:

type MyType = {
    [key: string]: string | undefined
}

const someFunction = (): MyType => 45

The above function's return type is MyType but we are returning a number, thus TypeScript gives an error: Type '45' is not assignable to type 'MyType'

The problem with the below code:

in the below example the networkStateReducer expected to return NetworkState type. However, even though it returns data not conforming to NetworkState, there is still no error.

if we look closely to the first case for example:

case NetworkActionType.Failure:
      return { ...state, [action.payload.sagaAction]: 'SHOULD_BE_ERROR' }

assuming the state is an empty object initially, the return value is: [action.payload.sagaAction] equalling some string, but the signature clearly sets that as an object:

  [key in NetworkSagaAction]?: {
    networkError?: Error
    networkStatus: NetworkStatus
  }

However, we get no error from TypeScript.

The actual code:

export type NetworkSagaAction = 'Login' | 'Logout'

export type NetworkStatus = 'idle' | 'pending' | 'failure' | 'success'

export enum NetworkActionType {
  Busy = 'Network/Busy',
  Failure = 'Network/Failure',
  Idle = 'Network/Idle',
  Reset = 'Network/Reset',
  Success = 'Network/Success',
}

export type NetworkState = {
  [key in NetworkSagaAction]?: {
    error?: Error
    networkStatus: NetworkStatus
  }
}

export interface NetworkPayload {
  sagaAction: NetworkSagaAction
}

export const initialState: NetworkState = {}

type FunctionType = (...args: any[]) => any

interface ActionCreatorsMapObject {
  [actionCreator: string]: FunctionType
}

export type ActionsUnion<A extends ActionCreatorsMapObject> = ReturnType<A[keyof A]>

export interface Action<T extends string> {
  type: T
}

export interface ActionWithPayload<T extends string, P> extends Action<T> {
  payload: P
}

export type BusyAction = ActionWithPayload<NetworkActionType.Busy, NetworkPayload>

export function createAction<T extends string, P>(type: T, payload: P): ActionWithPayload<T, P>
export function createAction<T extends string, P>(type: T, payload?: P) {
  return payload === undefined ? { type } : { type, payload }
}

export type NetworkActions = ActionsUnion<typeof NetworkActions>
export const NetworkActions = {
    busy: (payload: NetworkPayload): BusyAction => createAction(NetworkActionType.Busy, payload),
}

const networkStateReducer = (
    state = initialState,
    action: NetworkActions,
): NetworkState => { 
    return {
        [action.payload.sagaAction]: 'THIS SHOULD BE OBJECT BUT NOT AND STILL NO TYPE ERROR'
    }
}

Upvotes: 2

Views: 3395

Answers (1)

jered
jered

Reputation: 11571

TypeScript can't properly infer what the return type of networkStateReducer() will be because you're returning an object with a computed value, the concrete value of which will not be known until runtime. In fact, you've written it in such a way that it is impossible to infer what the returned object structure will be (and is therefore impossible to throw a TS warning for).

Consider just the function networkStateReducer():

const networkStateReducer = (
    state = initialState,
    action: NetworkActions,
): NetworkState => { 
    return {
        [action.payload.sagaAction]: 'THIS SHOULD BE OBJECT BUT NOT AND STILL NO TYPE ERROR'
    }
}

Here, action.payload.sagaAction will be of type NetworkSagaAction, one of two strings, either Login or Logout. TypeScript knows that the object returned from this function can have the optional keys matching the type of NetworkSagaAction, so it gets as far as checking those. But it has no idea what the value of the object at those keys is supposed to be. Essentially, the type of the key is easy to infer based on the static code as its written, but the type of the value at that key is impossible to infer until runtime because it depends on the key value.

Things are confused a bit for you here because the types of both values are supposed to be the same:

export type NetworkState = {
  [key in NetworkSagaAction]?: {
    error?: Error
    networkStatus: NetworkStatus
  }
}

But imagine a case where the types of both values are actually different:

export type NetworkState = {
  Login?: {
    error?: Error
    networkStatus: NetworkStatus
  }
  Logout?: string
}

Now look at the code of your original function again:

const networkStateReducer = (
    state = initialState,
    action: NetworkActions,
): NetworkState => { 
    return {
        [action.payload.sagaAction]: 'THIS SHOULD BE OBJECT BUT NOT AND STILL NO TYPE ERROR'
    }
}

Is the return type of this function valid, or invalid? If the value of action.payload.sagaAction is Login then it's invalid because instead of a string it should be an object. But if the value is Logout then the return type is valid, because a string is ok. So which is it? Who knows! (until runtime)

Now in theory you might think that TypeScript should be able to infer this if the code is written such that it guarantees that all keys of the object would have the same type. We could even make it more explicit by doing this:

export type NetworkSagaActionValue = {
  error?: Error
  networkStatus: NetworkStatus
}

export type NetworkState = {
  [key in NetworkSagaAction]?: NetworkSagaActionValue
}

But that does not seem to solve the issue. There may be some very smart, complicated, and intentional reason by the TS devs for why this does not work. Or perhaps it is just an oversight or bug that will be fixed in the future.

For now I see you having two main options, and they both involve simply changing your networkStateReducer() function slightly.

You could enforce the type of the object values separately from the function return like this:

const networkStateReducer = (
  state = initialState,
  action: NetworkActions,
): NetworkState => { 
  const returnValue: NetworkSagaActionValue = 'THIS SHOULD BE OBJECT BUT NOT AND STILL NO TYPE ERROR';
  // 'returnValue' errors because types don't match
  return {
      [action.payload.sagaAction]: returnValue
  }
}

Or, a bit more wordy but could be better depending on your needs - simply check the value of action.payload.sagaAction and branch from there, allowing you to explicitly define the returned objects individually:

const networkStateReducer = (
    state = initialState,
    action: NetworkActions,
): NetworkState => { 
  switch (action.payload.sagaAction) {
    case "Login":
      return {Login: 'THIS SHOULD BE OBJECT BUT NOT AND STILL NO TYPE ERROR'};
      // above return errors because types don't match
    case "Logout":
      return {Logout: {networkStatus: 'idle'}};
  }
}

I'd be interested to know myself if there is a way to solve this in a more elegant way with only computed values, but as far as I know there is none.

Upvotes: 2

Related Questions