Search code examples
typescriptgenericsreact-hookstype-narrowing

TypeScript how to type narrow a generic with typeof?


I'm experimenting with writing a custom React hook that stores state in the URL. As part of that I set the default type of the state to be string (since the URL is a big string) and I provide a customValueConverters parameter which allows people to go from their custom type to a string and back again.

The defaultValue has the same type as the type of the state and so I was hoping to use that to type narrow from TState to string and use the stringValueConverters in that case:

import { useSearchParams } from "react-router-dom";

export function useSearchParamsState<TState = string>(
    searchParamName: string,
    defaultValue: TState,
    customValueConverters?: ValueConverters<TState>
): readonly [
    searchParamsState: TState,
    setSearchParamsState: (newState: TState) => void
] {
    const [searchParams, setSearchParams] = useSearchParams();

    let valueConverters = customValueConverters;

    if (!customValueConverters && typeof defaultValue === "string")
        valueConverters = stringValueConverters;

    const acquiredSearchParam = searchParams.get(searchParamName);
    const searchParamsState = acquiredSearchParam
        ? valueConverters.parse(acquiredSearchParam)
        : defaultValue;

    const setSearchParamsState = (newState: TState) => {
        const next = Object.assign(
            {},
            [...searchParams.entries()].reduce(
                (o, [key, value]) => ({ ...o, [key]: value }),
                {}
            ),
            { [searchParamName]: valueConverters.stringify(newState) }
        );
        setSearchParams(next);
    };
    return [searchParamsState, setSearchParamsState];
}

interface ValueConverters<TState> {
    parse: (state: string) => TState;
    stringify: (state: TState) => string;
}

const stringValueConverters: ValueConverters<string> = {
    parse: (state: string) => state,
    stringify: (state: string) => state,
};

However this errors out with Type 'ValueConverters<string>' is not assignable to type 'ValueConverters<TState>'.. This surprises me as I would hope TypeScript would detect the type narrowing on defaultValue and allow this. It doesn't...


Solution

  • Rather than simply addressing the question you asked, I've created a refactor / alternative implementation because you said that you were interested.

    For context — because you're using the useSearchParams hook — here are its associated types from its documentation page:

    declare function useSearchParams(
      defaultInit?: URLSearchParamsInit
    ): [URLSearchParams, SetURLSearchParams];
    
    type ParamKeyValuePair = [string, string];
    
    type URLSearchParamsInit =
      | string
      | ParamKeyValuePair[]
      | Record<string, string | string[]>
      | URLSearchParams;
    
    type SetURLSearchParams = (
      nextInit?: URLSearchParamsInit,
      navigateOpts?: : { replace?: boolean; state?: any }
    ) => void;
    

    One of the core features of URLSearchParams that distinguishes it from an object or even a Map is that it allows for repeated/duplicate keys. In the code you've shown, any potential serial values associated with the same key are collapsed (overwritten by the last in the series):

    [...searchParams.entries()].reduce(
      (o, [key, value]) => ({...o, [key]: value}),
      {}
    )
    

    Additionally, it might also be nice to allow for specifying the additional options argument to the SetURLSearchParams function returned by useSearchParams:

    navigateOpts?: : { replace?: boolean; state?: any }
    

    The implementation below acknowledges the above, and it also uses a series of function overloads to provide specialized return types, inferred from each combination of input options (the comments in the usage section of the code describe this more).

    If you have any follow up questions, feel free to post a comment in reply.

    TS Playground

    import {useSearchParams} from 'react-router-dom';
    
    type Maybe<T> = T | undefined;
    type Entry<Key, Value> = [Key, Value];
    
    function identity <T>(value: T): T {
      return value;
    }
    
    type StringTransformers<T> = {
      parse: (value: string) => T;
      stringify: (value: T) => string;
    };
    
    type ParsedSearchParamSetter<T> = (
      nextValue: T,
      navigateOptions?: Parameters<ReturnType<typeof useSearchParams>[1]>[1],
    ) => void;
    
    type SearchParamsState<T, Optional = false> = readonly [
      value: Optional extends true ? Maybe<T> : T,
      setter: ParsedSearchParamSetter<T>,
    ];
    
    function useSearchParamsState <T>(options: { name: string; default: T; } & StringTransformers<T>): SearchParamsState<T>;
    function useSearchParamsState <T>(options: { name: string } & StringTransformers<T>): SearchParamsState<T, true>;
    function useSearchParamsState (options: { name: string; default: string; }): SearchParamsState<string>;
    function useSearchParamsState (options: { name: string }): SearchParamsState<string, true>;
    function useSearchParamsState <T>(options: (
      & { name: string; default?: T; }
      & Partial<StringTransformers<T>>
    )) {
      const {name, parse = identity<string>, stringify = identity<string>} = options;
      const [params, setParams] = useSearchParams();
      let value: T | string | undefined = params.get(name) ?? undefined;
      if (typeof value === 'string') value = parse(value);
      else if (typeof options.default !== 'undefined') value = options.default;
    
      const setter: ParsedSearchParamSetter<T> = (value, ...rest) => {
        const entries: Entry<string, string>[] = [];
    
        for (const entry of params.entries()) {
          if (entry[0] !== name) entries.push(entry);
        }
    
        const v = (stringify as StringTransformers<T>['stringify'])(value);
        entries.push([name, v]);
        setParams(entries, ...rest);
      };
    
      return [value, setter] as const;
    }
    
    
    // Usage:
    
    // No args:
    useSearchParamsState(); /* Error 2554
    ~~~~~~~~~~~~~~~~~~~~~~ */
    
    // No options:
    useSearchParamsState({}); /* Error 2769
                         ~~ */
    
    // No default results in possibly undefined value:
    const [
      v1, // const v1: Maybe<string>
      s1, // const s1: ParsedSearchParamSetter<string>
    ] = useSearchParamsState({name: 'hello'});
    
    // Including a default results in a definite value:
    const [
      v2, // const v2: string
      s2, // const s2: ParsedSearchParamSetter<string>
    ] = useSearchParamsState({name: 'hello', default: 'world'});
    
    // The type of the default value must match the return type of
    // the provided transofrm fns (or be a string). The transform fns aren't provided,
    // so a "number" is not valid here:
    useSearchParamsState({name: 'hello', default: 2}); /* Error 2769
                                         ~~~~~~~~~~ */
    
    // Missing "stringify" transform fn:
    useSearchParamsState({
      name: 'hello',
      parse: (str: string) => str.length > 2, /* Error 2769
      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */
    });
    
    // Missing "parse" transform fn:
    useSearchParamsState({
      name: 'hello',
      stringify: (bool: boolean) => '', /* Error 2769
      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */
    });
    
    // No default results in possibly undefined value:
    const [
      v3, // const v3: Maybe<boolean>
      s3, // const s3: ParsedSearchParamSetter<boolean>
    ] = useSearchParamsState({
      name: 'hello',
      parse: (str: string) => str.length > 2,
      stringify: (bool: boolean) => '',
    });
    
    // Including a default results in a definite value:
    const [
      v4, // const v4: boolean
      s4, // const s4: ParsedSearchParamSetter<boolean>
    ] = useSearchParamsState({
      name: 'hello',
      default: true,
      parse: (str: string) => str.length > 2,
      stringify: (bool: boolean) => '',
    });