Skip to content

isArray insufficiently narrowing when dealing with... unions of specific generics? #1238

Open

Description

@abrenneke

Hello! This playground shows the jist of the problem:

https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAbzsAzgQSlAhgTzgXzgDMoIQ4ByKAUxGoBMsKBuAKFZhzGrjQB4AKgD44AXkSs4UuFgBccAW3zsiAVwB2AYxjAI64oIA0CgGpYANqp7UAHjGrr6KXoJEAfODSz095vP2EAbQBdIQAKEFwAI2oMKHkBM0tqAEoJaThNPRR4QIA3CytgsWR0TFwI6NjMNIB+OEicGLi4eUDG5sxgtgyAel6MuAA9WtZlVjUtHT1iACZXSqbq+IU4DwEQtIRJaSz1HLh8wupi8VQ4io7luoaqlraruO6dqX7B4dHxt4B1aABrLCkDT0Ua2SCwYgabS6fTnco4WZhArJeQAJWo3l8OAuOD4Gj+6ggAHd1O44PjCSSUvJkVZSnB0Zj1H4cXj1ATiaT0p5qDBVFB9DiAHRw7A4JHHFJKFRQ6b6IgAZiMpmOcFs9kczgCZK8PmZ-lcIXCj0wCSSVi2L0y2VytJOJVFuERJqgNxdrUOLuefQGGRGYxlUxhxAALAsXQk1gpNtypHsDkdkqdSjjnXcanB6u6HumoN7pG9Bv78EA

Copied here for posterity:

import { isArray } from 'remeda';

type A<T> = {
    a: T;
}

function f<T, TValue extends A<T> | readonly A<T>[]>(maybeArr: TValue) {
    const [value] = isArray(maybeArr) ? maybeArr : [maybeArr];
    //     ^?
}

function f2<T>(maybeArr: T | T[]) {
    const [value] = isArray(maybeArr) ? maybeArr : [maybeArr];
    //      ^?
}

// Workaround?
export function isArray2(value: ReadonlyArray<unknown> | unknown): value is ReadonlyArray<unknown> {
  return Array.isArray(value);
}

function f3<T, TValue extends A<T> | readonly A<T>[]>(maybeArr: TValue) {
    const [value] = isArray2(maybeArr) ? maybeArr : [maybeArr];
    //     ^?
}

function f4<T>(maybeArr: T | T[]) {
    const [value] = isArray2(maybeArr) ? maybeArr : [maybeArr];
    //      ^?
}

Namely, the type of value in f is unknown rather than A<T>. In the real code, it's much more complex types from drizzle - This is as much as I was able to simplify the code to reproduce the problem.

Some previous developer here worked around the problem by writing out own isArray (above) which seems to get around the problem in a pretty simple way.

This could definitely be TS working as intended, somewhere along the line. Could easily some compromises you've made in remeda for other use cases. But bringing it up in case you weren't aware.

Thanks!

Activity

  1. eranhirsch commented on Dec 28, 2025

    @eranhirsch
    Member

    I think that this is due to a limitation of TypeScript in general, when it comes to tracking generic parameter types throughout a function's logic. The gist is that TypeScript infers "early", converting anything derived from a generic parameter to what they are in the type's definition, instead of keeping it generic and coupled with the type-param it was inferred from.

    The easiest example would be:

    function foo<T extends readonly unknown[], S extends readonly unknown[]>(
      ts: T,
      ss: S,
      callbackT: (item: T[number] | undefined) => void,
      callbackS: (item: S[number] | undefined) => void,
    ): void {
      const t = ts[0];
      //    ^? const t: unknown
      const s = ss[0];
      //    ^? const s: unknown
    
      // These just happen to work because the generic typing *happens* to work.
      callbackT(t);
      callbackS(s);
    
      // Because this is broken and TypeScript isn't saying anything about it...
      callbackT(s);
      callbackS(t);
    }

    The only reason using the native Array.isArray works in your example is that it narrows the array down to any[] instead of unknown[] so you are effectively turning off type-checking by using it. This is one of the reasons that the Remeda isArray returns unknown[] to begin with, and why it's safer to use than the native one.

    function bar<T, S extends T | readonly T[]>(data: S): [T, T] {
      const remedaIsArray = isArray(data) ? data[0] : data;
      //    ^? const remedaIsArray: unknown
      const nativeIsArray = Array.isArray(data) ? data[0] : data;
      //    ^? const nativeIsArray: any
     
      // TypeScript would surface `remedaIsArray` as being wrong (because `unknown` is not `T`)
      // but it wouldn't surface nativeIsArray as being wrong because type checking is turned off
      // and not because it's correctly type-checked.
      return [remedaIsArray, nativeIsArray];
    }

    Here's my issue in the TypeScript project which you can thumb up to help prioritize: microsoft/TypeScript#61750

    https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAbzsAzgQSlAhgTzgXzgDMoIQ4AiKAUxGoBMsKBuAKFaIFcA7AYxmARuxCBAA8AFTjUAHjGrd6KODSz0hAGzw8A1twgB3bgG0AugBo4AZWlyFSldTWbt3PYZOmAfAApWcOBgUAC44CXN-OBQQ6wiA3iwNDQAjLF4dCVCfYHkQUIljbk4QZOooUzgAHzgeemoiYG4GAEo4AF4vOAA3CGB6OLgEpNT0qyyc2lCrQuLS8qqaxXrGlvbOnr6I5tCN+kRI3iEUeHg2wJRjAAZTNgCAejuAgIA9AH4Do-hlM+irm8iHk84G92PdHhIABbUFDUOAAK04xzgEKwYDACkCEDgBmgOjgpQSiNhMChcAA5goysBeIEcGBGmS4AAqFFohQoJmY7G4gB0B0SKTSGR8MGat0GApGOisPhQYtBcEBACFqISYYEIahkMpkqQdBisIownTqFZeFBgGB4KhuAByL64BlwQ04ElOrDJCCca0wHl+-nDIUSWXy+KSoUy0VsfDsLh8ARCfFYKCSSw2WTyRTKKTVVTqbhaMJmXyMGBYKbbODGcJhCoID7cJE0OiMACS6EwuHa2ow2BwPlLWFarzgg7+cFCg-FgKeIPinzg3CwAi61Hbva7Zw3OB5qG3A+XQ7gI7H1wno8P08es-ekRoME4UGExmbDCw687OEsS5Xa47ff+GMgA

  2. added theissue type on Dec 28, 2025
  3. eranhirsch commented on Dec 29, 2025

    @eranhirsch
    Member

    I wanted to double-check myself on this and now think that my explanation above is incorrect and that this is a real bug, but I can't find a way to fix it without breaking "headless" invocations of the function (e.g. filter(DATA, isArray)) and that is a big enough breaking change that I can't do it without bumping the major version for Remeda.

    I have a plan to remove "headless" invocations from our type-guards (isString, isNumber, etc...) for Remeda V3 anyway, so this is definetly planned.

    I'll keep this issue open for now so I don't forget looking into it once we start working on v3.

  4. added
    v3Issues and PRs related to Remeda v3 (3.0.0) major version bump.
    on Dec 29, 2025
  5. abrenneke commented on Dec 29, 2025

    @abrenneke
    Author

    Thanks for re-opening. Yeah the Array.isArray in my workaround shouldn't be affecting the typing of isArray2 at all. Too bad you haven't been able to fix it though. Isn't TypeScript fun sometimes? I'll keep an eye out for V3!

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

    typingSomething type relatedv3Issues and PRs related to Remeda v3 (3.0.0) major version bump.

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions