Home > Net >  TypeScript toSafeArray: type a function that converts anything into an valid array
TypeScript toSafeArray: type a function that converts anything into an valid array

Time:11-12

How do I type a function that turns anything into a safe array?

toArray(null) // []: []
toArray(undefined) // []: []
toArray([]) // []: []
toArray({}) // [{}]: {}[]
toArray(0) // [0]: number[]
toArray('') // ['']: string[]
toArray(['']) // ['']: string[]
toArray([1]) // [1]: number[]

I currently have:

function toArray<T>(
  value: T
): T extends null | undefined ? [] : T extends Array<infer U> ? U[] : [T] {
  if (value == null) {
    // It's invalid, return an empty array.
    const safeArray: [] = []
    return safeArray
  }

  if (Array.isArray(value)) {
    // It's already an array, return it unmodified.
    const safeArray: T[] = value
    return safeArray
  }

  // It's valid, but not an array, turn it into an array.
  const safeArray: [T] = [value]
  return safeArray
}

But anything I return, TypeScript yells at me saying it's not assignable to the return type.

Previously I had:

declare function toArray<T>(value: T | Array<T>): Array<T>

But it wrongly returns null[] and undefined[] which is undesired.

CodePudding user response:

This is currently a design limitation or missing feature of TypeScript. See microsoft/TypeScript#33912 for an authoritative discussion.

The TypeScript compiler is not really able to perform much reasoning about conditional types that depend on as-yet unspecified generic type parameters. Such types are essentially opaque to the compiler, and it cannot verify that a particular value is assignable to it.

When you call toArray(), the type parameter T is specified as some specific type (usually this is inferred by the compiler) and then the return type T extends null | undefined ? [] : ... can be evaluated to some specific type itself. But in the body of toArray(), the type parameter T is not specified. And so T extends null | undefined ? [] : ... is just some "black box" type. You can check that value is null, but this does not narrow or specify the type parameter T, and so the compiler has no way to know if the value [] is assignable to T extends null | undefined ? [] : .... And you get compiler errors at each return statement:

function toArray<T>(
  value: T
): T extends null | undefined ? [] : T extends ReadonlyArray<any> ? T : [T] {
  if (value == null) { return []; } // error!
  if (Array.isArray(value)) { return value; } // error!
  return [value]; // error!
}

Until and unless microsoft/TypeScript#33912 is addressed, the only viable approach for such generic conditional types is to accept that the compiler is unable to verify that the implementation satisfies the call signature, and take the responsibility for such verification yourself. One way to do that is with a type assertion at each place where the compiler cannot verify assignability:

type ToArray<T> =
  T extends null | undefined ? [] :
  T extends ReadonlyArray<any> ? T :
  [T];

function toArray<T>(
  value: T
): ToArray<T> {
  if (value == null) { return [] as ToArray<T>; }
  if (Array.isArray(value)) { return value as ToArray<T>; }
  return [value] as ToArray<T>;
}

Now there are no errors. Note that you need to make sure you do the right thing; you could change value == null to value != null and still there would be no errors. The compiler is trusting you when you say as ToArray<T>, so make sure not to lie to it.

Anyway, that works but is tedious. I made a type alias of ToArray<T> to prevent writing out that long type every time, or giving up and writing as any. In cases like this, I tend to not use type assertions and opt for single-call-signature overloads instead:

// call signature
function toArray<T>(
  value: T
): T extends null | undefined ? [] : T extends ReadonlyArray<any> ? T : [T];

// implementation
function toArray(value: unknown) {
  if (value == null) { return []; }
  if (Array.isArray(value)) { return value; }
  return [value];
}

Function overload implementations are checked more loosely than those of regular functions. So the above compiles with no error. Callers see only the one call signature, while the implementation is allowed to be looser and return a value of type unknown. This is just as unsafe as type assertions (again, != null would not be caught), but the implementation is cleaner and there's a clearer separation between the typing as seen by callers and that seen by the implementation.

Playground link to code

  • Related