Skip to content

Array.isArray() : a possible fix ? + discussion on type guards (frozen keyword + better type deduction) #60177

Open

Description

🔍 Search Terms

in:title frozen
in:title freeze

  • I don't remember for Array.isArray()

I searched for issues on Array.isArray() and found a lot of them, too much to list them all.
3 weeks ago I suggested something that could lead to a possible fix on an existing issue.

✅ Viability Checklist

⭐ Suggestion

The type guard for Array.isArray() is currently erroneous and the fix potentially quite complex.
In retrospect I think the potential fix I suggested previously is more a "workaround" (still, you can get a look at it), and that there are issues on type guards.

I'd like to discuss here what should be the behavior of Array.isArray() on different cases, and to discuss possibilities to simplify the current "workaround" by improving type guards behavior.

Summary :

  • a frozen keywords putting never to some properties (alternatively could also be a frozen state e.g. to some functions, preventing their call/making them non-callable) to act as a constraint as opposed to readonly which is only a partial interface. This could help solving the current issue of using readonly as a constraint, which generate lot of unsoundness + would solve use of readonly arrays with Array.isArray().
  • better deducing generic types could also improve type guards.

First, for the sake of simplicity, let's assume :

function isArray(a: unknown): a is unknown[];

type A = typeof a;
Is   <A, unknown[]> // the type of a if isArray() is TRUE
IsNot<A, unknown[]> // the type of a if isArray() is FALSE

In the general case :

Is   <T, U> = T&U;
IsNot<T, U> = Exclude<T,U>;

Of course, type guards need to do more than that, and are currently doing more, but not enough.

Union:

Is<T1|T2, U> = Is<T1, U> | Is<T2, U>;

Currently, Type guards and & seem to behave as expected.
Currently, (T1|T1)&U is distributed as (T1&U) | (T2&U) to remove some never then factorized when the type is printed.

Child class :

Is<T extends U, U> = T;

Currently, Type guards behave as expected, but & doesn't (but not an issue).

class A<T> extends Array<T> { /* ... */ }

type T = A<any> & Array<any>; // A<any> expected, got A<any> & any[].

Base type

Is<T, U extends T> = U;

Currently, Type guards behave as expected, but & doesn't (but not an issue).

interface A {
        get length(): number;
    }

type C = A & Array<any>; // expected Array<any>, got Array<any> & A.

Readonly:

There is 2 ways to see readonly :

  • as a constraint : readonly T & T = readonly T.
  • as a partial interface : readonly T & T = T.

For TS, it is saw as a partial interface, therefore : readonly T & T = T.
Which is quite confusing as, in practice, we mainly use it as a constraint, but this is a design choice, why not.

The issue is that the type of ̀Object.freeze([]) is also a readonly [], when this is not a partial interface, BUT a constraint.
This is an inconsistency in the design, which could be solved with e.g. a frozen keyword : frozen number[] which would set some properties/methods as never instead of simply removing them :

    type A = {
        a: 3,
        b: 4
    }

    type Excl<T, keys> = {
        [K in keyof T]: K extends keys ? never : T[K]
    }

    type B = Excl<A, "b">;
   // or type B = number [] & { push: never };

    type C = B&A;
    let c = f<C>();
    c.b // never

Currently, Array.isArray(readonly T[]), assert T as being any[], which is wrong for 2 reasons :

  1. the generic type information is lost.
  2. the readonly information is lost.

I argue that as Array.isArray(Object.freeze([])) returns true, so we shouldn't remove the readonly keyword.
But should it be at the type guard level, or at the Array.isArray() call signature level ?

On one side readonly is only a partial interface, and on the other side, it is often used as a constraint (a frozen keyword would solve this).
On another side, readonly is at the type level, when the type guard function is based on the value during execution.

Therefore, without frozen there is 4 solutions :

  1. Add readonly at the Array.isArray() level, and require other devs to do so for their type guards.
  2. Handle readonly at the type guard level, with Is<readonly T, U> = readonly (T&U), which would be ambiguous as readonly isn't a constraint, but a partial interface.
  3. In type guards, if T extends readonly U, makes U implicitly readonly. Is<T extends readonly U, U> = T & readonly U otherwise Is<T,U> = T&U, which might also be confusing.
  4. Assume that type guards offer information on the runtime value, but not on the desired TS type, i.e. : Is<T, U extends T> = T and requires an explicit cast to get a U (which would always be legit), which would be horrible.

And here the good stuff, with generics...

**Base type + generics **

    interface A {
        push(...args: number[]): void;
        pop(): number|undefined;
    }

    type X = Array<number> extends A ? true : false; // true
    let a = f<A>();

    if(Array.isArray(a) )
        a; // any[] <- should be number[]

We should try to assert the generic types :

// with U<number> extends T;
Is<T, U<unknown>> = Is<Partial<U<number>>, U<unknown>> = U<unknown&number> = U<number>; 

I think a type deduction is technically possible in lot of cases, an would simplify lot of type guards using generics.
The issue is to assert when the following step would be legal in a type guard :

Is<Partial<U<number>>, U<unknown>> = U<unknown&number>

Maybe if, and only if, U<unknown&number> extends U<unknown> ?

We could even be more generic :

// with U<number> extends Pick<T, keyof U>;
Is<T, U<unknown>> = Is<Partial<U<number>>, U<unknown>> & T = U<unknown&number> & T = U<number> & T; 

When we can't deduce, I suggest:

  • Is<unknown, U<V>> = U<V>
  • Is<any, U<V>> = U<V>, but U<any> if V = unknown.
  • Is<{}, U<V>> = U<V>.

This issue also occurs with readonly unknown[], as it can be seen as a base type of unknown[].

📃 Motivating Example

This would lead to more precise type deduction in type guards.

💻 Use Cases

  1. What do you want to use this for?

Deduce type more precise types.

  1. What shortcomings exist with current approaches?

Deduced types are incorrect/not precised.

  1. What workarounds are you using in the meantime?

Complex type guards.

Activity

  1. denis-migdal commented on Oct 10, 2024

    @denis-migdal
    Author

    I'll discuss a little the behavior of the "frozen" keyword that could help to solve issues with readonly (I can open a new issue if necessary).

    Let assume frozen T = Freeze<T>.

    1. the frozen state needs to be contagious : Freeze<T> & U = Freeze<T&U>.
      It can be implemented :
    • at TS language level.
    • as a [isFrozen]: never property acting as a flag (ofc not ideal) : Freeze<T> = T & {[isFrozen]: never}.
    1. we need to make some call signatures non callable.
      It can be implemented :
    • at TS language level.
    • by making the property of type never, but in reality the function still exists, and it will forbid all of its call signature.
    • by making some call signatures returning never, which is close to runtime behavior as the function is callable but raises an exception, however, TS won't warn us when we call it.
    • replace the method type of this by never : the error message isn't quite explicit, but it seems to work.
    1. We need to mark the call signature so that they will be callable in the general case, but non callable when the type is frozen.
      It can be implemented :
    • at TS language level : e.g. a "const" keyword for functions (like in C++).
    • by foo(this: NonConst<this>, ...) with NonConst<T> = T extends Freeze<{}> ? never : T. It works, but not efficient as it'd makes all class generics. Also, we mark the non-const when we should mark the const call signatures.

    PoC :
    Playground Link

  2. denis-migdal commented on Oct 12, 2024

    @denis-migdal
    Author

    Update: it seems TS is already able to assert generics types of base types:

    interface A {
        push(...args: number[]): number;
        pop(): number|undefined;
    }
    
    type PickMatching<V, T> = { [K in keyof T]: (T&V)[K] };
    type Z =  A extends PickMatching<Array<infer T>, A> ? T[] : never; // number[]
    
    interface A2 {
        [key: number]: number;
        push(...args: number[]): number;
        pop(): number|undefined;
    }
    type Z2 =  A2 extends PickMatching<Array<infer T>, A> ? T[] : never; // number[]

    I updated my Array.isArray() workaround to include it.

    Then, for type guards, should it be done by TS itself, or by devs in the function signature ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Awaiting More FeedbackThis means we'd like to hear from more people who would be helped by this featureSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions