Search code examples
reactjstypescriptreact-hookstypescript-typingstypescript-generics

Defining and Asserting Return Types Based on Dynamic Formatting in React Hook


Problem Description:

I have a custom React hook useFetchSelectOptions that fetches data and formats it into selectable options. Depending on the returnForm parameter, the options need to be either of type SelectOption[] | null or `Record<string, string> | null, but never a mix of both.

I want to ensure that the return type of options inside the hook strictly matches the specified returnForm. For instance:

  • If returnForm is 'arr', options should be of type SelectOption[] | null.
  • If returnForm is 'dict', options should be of type Record<string, string> | null.

Currently, the options type could potentially be Record<string, string> | SelectOption[] | null due to the conditional formatting in the hook, which can lead to ambiguity for the consumer of this hook.

Goal:

I would like to implement an assertion mechanism inside the hook to ensure that options adheres strictly to the determined type based on the returnForm parameter. This way, consumers using the hook can confidently rely on the inferred type without additional type checks.

Code Sample:

import { useState, useEffect } from 'react';

export interface SelectOption {
  label: string;
  value: string;
}

// Define a type for the return form
type ReturnForm = 'dict' | 'arr';

export function useFetchSelectOptions<T extends { name: string; id: string }>(
  fetchData: () => Promise<T[]>,
  returnForm: ReturnForm = 'arr', // Default to array format if not specified
): [T[] | null, SelectOption[] | Record<string, string> | null] {
  const [data, setData] = useState<T[] | null>(null);

  useEffect(() => {
    const fetchDataAndMapToOptions = async () => {
      try {
        const fetchedData = await fetchData();
        setData(fetchedData);
      } catch (error) {
        console.error('Error fetching data:', error);
        setData(null);
      }
    };

    fetchDataAndMapToOptions();
  }, [fetchData]);

  let options: SelectOption[] | Record<string, string> | null = data
    ? data.map((item) => ({ label: item.name, value: item.id }))
    : null;

  // Assertion to strictly type 'options' based on 'returnForm'
  if (returnForm === 'dict') {
    options = options?.reduce(
      (acc, curr) => {
        if (curr) {
          acc[curr.value] = curr.label;
        }
        return acc;
      },
      {} as Record<string, string>,
    ) ?? null;
  }

  return [data, options];
}

Additional Notes:

I've added a basic type assertion within the useFetchSelectOptions hook to refine the type of options based on the returnForm parameter. This ensures that options will conform to either SelectOption[] | null or Record<string, string> | null as expected, depending on the specified returnForm.

I'm seeking feedback on this approach and any potential improvements or alternative strategies to achieve strict typing of options within the context of the hook.

Thank you for your assistance!


Solution

  • consumers using the hook can confidently rely on the inferred type without additional type checks.

    Because the return type of the hook is already typed as [T[] | null, SelectOption[] | Record<string, string> | null], that is all that callers will see. Which is not what you are looking for, IIUC?

    assertion mechanism inside the hook

    Whatever is done inside the function does not affect the "outside", since the return type is already defined, as said above. TS will only check that the actual value that is returned satisfies said return type.


    If you want callers to see the return type corresponding to the returnForm value, then you should rather work on the Hook function signature. In your case, a function overload is probably the most appropriate:

    export function useFetchSelectOptions<T extends { name: string; id: string }>(fetchData: () => Promise<T[]>, returnForm?: "arr"): [T[], SelectOption[] | null]
    export function useFetchSelectOptions<T extends { name: string; id: string }>(fetchData: () => Promise<T[]>, returnForm: "dict"): [T[], Record<string, string> | null]
    export function useFetchSelectOptions<T extends { name: string; id: string }>(
        fetchData: () => Promise<T[]>,
        returnForm: ReturnForm = 'arr', // Default to array format if not specified
    ): [T[] | null, SelectOption[] | Record<string, string> | null] {
     // Implementation (untouched)
    }
    
    const arr = useFetchSelectOptions(async () => []);
    //    ^? [never[], SelectOption[] | null]
    
    const dict = useFetchSelectOptions(async () => [], "dict");
    //    ^? [never[], Record<string, string> | null]
    

    Playground Link