Karpov Kirill
Karpov Kirill

Reputation: 23

Return a different type if array is empty in Typescript

I want to return a different type in the strict mode if the argument is an empty array:

public static fromArray<T extends number[] | ({ length: 0 } & never[])>(
  array: T,
): T extends { length: 0 } ? undefined : ListNode {
  if (array.length === 0) {
    return undefined; // Error: TS2322: Type 'undefined' is not assignable to type 'T extends { length: 0; } ? undefined : ListNode'.
  }

  const start = new ListNode(array[0]);

  ...

  return start; // Error: TS2322: Type 'ListNode' is not assignable to type 'T extends { length: 0; } ? undefined : ListNode'.
}

The expected behavior:

const a = ListNode.fromArray([]); // typeof a is undefined
const b = ListNode.fromArray([1]); // typeof b is ListNode

If I add some type casting, e.g.

  return start as T extends { length: 0 } ? undefined : ListNode;

then all works as expected. Is it possible to implement this without "hacks"?

Upvotes: 2

Views: 1118

Answers (2)

Consider this example:

class ListNode<T> {
    constructor(arg: T) { }
}

type IsLiteralNumber<N extends number> =
    (N extends number
        ? (number extends N
            ? false
            : true)
        : true)
{
    // false
    type Test1 = IsLiteralNumber<number>

    // false, because TS is unaware how long is any[] array
    type Test2 = IsLiteralNumber<any[]['length']>

    // true, it is clear that provided array has 3 elements
    type Test3 = IsLiteralNumber<[1, 2, 3]['length']>

    // true, 5 is a literal type
    type Test4 = IsLiteralNumber<5>
}

/**
 * Whole trick here is to check whether T[length]
 * property has literal number type (1,2,3,4) or no (number)
 */
type IsTuple<T> =
    /**
     * Check whether T is an array
     */
    (T extends Array<any> ?
        /**
         * Check whether T[length] has literal number type
         */
        IsLiteralNumber<T['length']>
        /**
         * If T is not array it is obvious that it should be false
         */
        : false)

{
    /**
     * false, because is it ibvious that type number[] does 
     * not have fixed length
     */
    type Test1 = IsTuple<number[]>

    // true, fixed length is 0
    type Test2 = IsTuple<[]>

    // true, fixed length is 3
    type Test3 = IsTuple<[1, 1, 1]>

}

/**
 * If argument is Tuple infer literal type from 
 * first element, otherwise return a union of all types of array elements 
 */
type ListNodeHead<Tuple extends any[]> =
    /**
     * Check whether TUple is actually tuple
     */
    IsTuple<Tuple> extends true
    /**
     * If yes - infer exact type of first element
     */
    ? Tuple extends [infer H, ...infer _]
    ? ListNode<H>
    : never
    /**
     * Otherwise return a union of all elements type
     * Added undefined because array might be empty and this
     * length might be known only in runtime
     */
    : ListNode<Tuple[number]> | undefined


class Foo {
    public static fromArray(array: []): undefined
    public static fromArray<Elem extends number, Tuple extends Elem[]>(array: [...Tuple]): ListNodeHead<Tuple>
    public static fromArray<Tuple extends number[]>(
        array: Tuple
    ): undefined | ListNode<number> {
        return array.length === 0 ? undefined : new ListNode(array[0])

    }
}


const result = Foo.fromArray([]) // undefined
const result2 = Foo.fromArray([1]) // ListNode<1>

const foo = (arg: number[]) => Foo.fromArray(arg) // ListNode<number> | undefined

Playground

It works for literal types and more general types (works inside higher order function foo)

IsTuple check whether array has fixed length or not.

Head - returns first element if list is a tuple or just a type of elements in the list if it is not a tuple.

TS does not support conditional types as a return type, this is why I have overloaded fromArray method.


Q & A

  1. Could you elaborate on that part N extends number? (number extends N in particular number extends N ?

N extends number - means that N is a subtype of number type. N has all props from number and may have some other extra props. See example:

type IsSubType<N> = N extends number ? true : false

type Test1 = IsSubType<number & { _tag: 'A' }> // true
type Test2 = IsSubType<5> // true
type Test3 = IsSubType<typeof Infinity> // true

All these types number & { _tag: 'A' }, 5, typeof Infinity are subtypes of number.

As for the second part: number extends N. See this example:

/**
 * This conditional type is important only in context of my answer,
 */
type IsSuperType<N> = number extends N ? false : true

type Test1 = IsSuperType<10> // true
type Test2 = IsSuperType<5> // true

type Test3 = IsSuperType<number> // false

Above IsSuperType utility type checks whether number is a subtype of N. If it is a subtype - than N is wrong because it might be much wider that number. For instance:

type Test1 = IsSuperType<any> // true

So we need to make sure that number is not subtype of N. See another example:

type IsLiteralNumber<N extends number> =
    /**
     * If N is a subtype of number
     */
    (N extends number
          /**
           * and number is not subtype of N
           */
        ? (number extends N
            ? false
            /**
             * it is literal type of number: 5, 10, 42 ....
             */
            : true)
        : true)
  1. About because TS is unaware how long is any[] array ....

Here is a type of Array from standard lib:

interface Array<T> {
  /**
   * Gets or sets the length of the array. This is a number one higher than the highest index in the array.
   */
  length: number;
}

Property length byt the default is just a number. However, TS is able to infer the length of the literal array tuple. That's it.

  1. Why then conditionals are allowed syntactically in the return type section if they are not supported

Conditional types are supported inside overload signatures because they (overloads) are less strict, in fact, they are bivariant.

Upvotes: 3

Hendrik Belitz
Hendrik Belitz

Reputation: 31

Using a return type of ListNode | undefined would be totally sufficient, there is no reason for a conditional type:

public static fromArray<T extends number[] | ({ length: 0 } & never[])>(
  array: T,
): undefined | ListNode { /* method implementation */ }

What was your intention to use a conditional type expression here in the first place? Since length's is a runtime property and there is no separate "empty array" type (there are good reasons why even purely functional languages still treat empty lists as lists), there is no way to distinguish an empty array from an array on the type level.

Upvotes: 0

Related Questions