TypeScript inferring first array element from the second array element

Say I have a variable arr that is either type [number, 'number'] or [null, 'null']. Is it possible to infer the type of arr[0] based on the value of arr[1]? Here is the issue, I don’t think my use case works with function overloads since the value of arr[1] is a side effect of the function returning it. Instead of arr[0] and arr[1] I’m using the following names data and status to represent those values respectively. In this case, data would be the data I’m trying to fetch from my API, and status is the status (idle, pending, success, or error) of the API request.

I’m thinking of useFetch as a state machine. Basically, I’m trying to have it infer the type of the machine context (data) from the state (status) of the machine. I thought about digging into XState’s source code cause I think they do something similar to this. See XStates docs.

I have the following React hook. See the useFetch function below:

export declare namespace FetchUtil {
  export type Status = 'idle' | 'pending' | 'error' | 'success'

  export type Response<Data, Status> = Readonly<[Data, Status]>

  export namespace States {
    export type Idle = null
    export type Pending = null
    export type Error = { messgage: string }
    export type Success<T> = T
  }
}

interface Config extends Omit<RequestInit, 'body'> {
  body?: Record<string, any>
}

export async function fetchUtil<T> (url: string, config?: Config): Promise<T | null> {
  let args: RequestInit = {}

  const { body, ...restConfig } = config ?? {}
  
  if (body) {
    args.body = JSON.stringify(body)
  }
  
  const response = await fetch(url, {
    cache: 'no-cache',
    ...restConfig,
    ...args,
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      ...restConfig.headers,
      ...args.headers,
    }
  })

  if (response.status >= 400) {
    const { error } = await response.json()
    throw new Error(error ?? 'failed to fetch')
  }

  return await response.json()
}

interface UseFetchConfig extends Config {
  enabled?: boolean
}

export function useFetch<T>(url: string, config?: UseFetchConfig) {
  const [status, setStatus] = React.useState<FetchUtil.Status>('idle')
  const [data, setData] = React.useState<T | FetchUtil.States.Error>()

  React.useEffect(() => {
    setStatus('idle')
    if (config?.enabled === false) {
      return
    }
    setStatus('pending')

    fetchUtil<T>(url, config)
    .then(res => {
      if (res !== null) {
        setData(res)
        setStatus('success')
      } else {
        setData({
          messgage: 'not found'
        })
        setStatus('error')  
      }
    })
    .catch(err => {
      setData(err)
      setStatus('error')
    })
  }, [url, config?.enabled])

  switch (status) {
    case 'idle':
      return [null, status] as FetchUtil.Response<FetchUtil.States.Idle, typeof status>
    case 'pending':
      return [null, status] as FetchUtil.Response<FetchUtil.States.Pending, typeof status>
    case 'success':
      return [data, status] as FetchUtil.Response<FetchUtil.States.Success<T>, typeof status>
    case 'error':
      return [data, status] as FetchUtil.Response<FetchUtil.States.Error, typeof status>
  }
}

And I can use the hook like so:

function Tweet({ id }) {
  const [tweet, status] = useFetch<API.Tweet>(urls.api(`/tweets/${id}`))

  React.useEffect(() => {
    if (status === 'idle') {
      // TS should infer that type of tweet is FetchUtil.States.Idle
    }

    else if (status === 'pending') {
      // TS should infer that type of tweet is FetchUtil.States.Pending
    }

    else if (status === 'success') {
      // TS should infer that type of tweet is FetchUtil.States.Success<API.Tweet>
    }
    
    else if (status === 'error') {
      // TS should infer that type of tweet is FetchUtil.States.Error
    }
  }, [tweet, status])

  // ...
}

The issue is TS is not detecting the type of tweet based on the type of status. Is it possible to infer the type of the first array item based on the type of the second array item? Thank you in advance.

Answer

This answer will use the example at the top instead of the one at the bottom; you can apply it there if you want.

Say you have the following union of tuples type:

type Arr = [number, 'number'] | [null, 'null'];

Such a union is considered a discriminated union; the property at index 1 is the discriminant because you can use it to tell which member of the union a value satisfies. (Such discrimination only works when the property you check is of a unit type: that is, a type to which only one value is assignable: like "number" or 123 or true or undefined or null, but not number or string.)

That means you should indeed be able to narrow a value arr of type Arr to the appropriate member of the union by checking arr[1]:

function f(arr: Arr) {
  switch (arr[1]) {
    case "null": {
      const [data, status] = arr;
      return "It's null";
    }
    case "number": {
      const [data, status] = arr;
      return data.toFixed(2);
    }
  }
}

Note that what you cannot do is to destructure arr into data and status before you do this check. That’s because once you do this, data and status will each be seen by the compiler as uncorrelated union types. It will know that data is number | null and that status is "number" | "null", but it has no way to keep track of the fact that data cannot be null if status is "number". The discriminated union nature has been lost.

While it would be nice if there were some way for the compiler to represent correlated unions, and while I have filed an issue, microsoft/TypeScript#30581, wishing for such a thing, it’s not likely to be possible in TypeScript for the foreseeable future. Instead, I’d suggest the workaround above: where you check arr[1] directly, and then destructure if you need to afterward, once per each scope in which arr has been narrowed. It’s redundant, but at least the compiler can verify type safety.

Playground link to code

Leave a Reply

Your email address will not be published. Required fields are marked *