Search code examples
arraystypescripttypespacking

How to make packing and unpacking functions generic?


I would like to make these two functions generic:

/**
 * Unpack an element if it is packed.
 * @param data - Data from which to unpack.
 */
export const unpack = (
  data: undefined | string | string[],
  index: number = 0,
): undefined | string => (Array.isArray(data) ? data[index] : data);

/**
 * Pack an element if it is unpacked.
 * @param data - Data to pack.
 */
export const pack = (
  data: undefined | string | string[],
): undefined | string[] =>
  Array.isArray(data) || data === undefined ? data : [data];

Here is my try:

/**
 * Unpack an element if it is packed.
 * @param data - Data from which to unpack.
 */
export const unpack = <T,>(
  data: T,
  index: number = 0,
): T extends unknown[] ? T[number] : T =>
  Array.isArray(data) ? data[index] : data;

/**
 * Pack an element if it is unpacked.
 * @param data - Data to pack.
 */
export const pack = <T,>(data: T): T extends unknown[] | undefined ? T : T[] =>
  Array.isArray(data) || data === undefined ? data : [data];

TypeScript Playground link

For the generic unpack function, TypeScript does not complain. However for the generic pack function, TypeScript generates this error:

Type 'T | (T & ({} | null))[]' is not assignable to type 'T extends unknown[] | undefined ? T : T[]'.
  Type 'T' is not assignable to type 'T extends unknown[] | undefined ? T : T[]'.

Is the generic unpack function correctly typed? How to fix the typing of the generic pack function?


Solution

  • TypeScript currently (as of TS5.4) can't use control flow analysis to affect generic type parameters directly. Inside

    const pack = <T,>(data: T): T extends unknown[] | undefined ? T : T[] =>
      Array.isArray(data) || data === undefined ? data : [data];
    

    the type checker can maybe understand that in the true branch of your conditional expression, data is of type T & (any[] | undefined). But the type parameter T is unaffected. So returning data in that case will fail to type check because you're assigning a T & (any[] | undefined) to a T extends unknown[] | undefined ? T : T[]. You'd like to say that checking data has in implication for T directly. But so far nothing like this is implemented and it's at least somewhat tricky to do it right (e.g., if you have a value t of type T extends string and you check t === "a", it doesn't mean that T is "a". T could be "a" | "b" | "c" or even just string. All you know is that "a" is assignable to T, not vice versa).

    So it becomes nearly impossible to implement a generic function that returns a conditional type in such a way that the compiler can accurately verify its safety.

    The currently open feature requests for improvements to this are microsoft/TypeScript#33014 and microsoft/TypeScript#33912. It's possible that sometime in the near future there will be movement here, since it's mentioned in the TypeScript 5.5 iteration plan at microsoft/TypeScript#57475. But for now it's not part of the language.


    All that means if you want to implement a generic function that returns a conditional type, you will probably need to use a type-safety-loosening feature inside the implementation, such as a type assertion or the any type or both:

    const pack = <T,>(data: T): T extends unknown[] | undefined ? T : T[] =>
      Array.isArray(data) || data === undefined ? data : [data] as any;
    

    Note that your unpack function didn't need this explicitly because any crept in there. In the true branch of Array.isArray(data) ? data[index] : data, the type of data becomes T & any[], and thus data[index] becomes T[number] & any, and that any turns the whole thing into any. Maybe Array.isArray() should narrow to unknown[] instead of any[], but that would break a lot of people's code (see microsoft/TypeScript#43865) so that's the way it is.

    Thus it isn't that unpack() compiled with no error because TypeScript actually verified it as safe. It's that the implementation was "infected" by any. Indeed you could make a blatant error and the compiler wouldn't notice:

    const unpack = <T,>(
      data: T,
      index: number = 0,
    ): T extends unknown[] ? T[number] : T =>
      Array.isArray(data) ? data[index] : "WHAAAAA"; // <-- 🤔
    

    All this is to say that whenever you have a generic conditional type, you'll need to take extra care to implement things safely, use type assertions or any to quell the compiler's warnings if necessary, and maybe in the future something better will come along.

    Playground link to code