microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.37k stars 12.4k forks source link

Infer type guard => array.filter(x => !!x) should refine Array<T|null> to Array<T> #16069

Closed danvk closed 6 months ago

danvk commented 7 years ago

TypeScript Version: 2.3

Code

const evenSquares: number[] =
    [1, 2, 3, 4]
        .map(x => x % 2 === 0 ? x * x : null)
        .filter(x => !!x);

with strictNullChecks enabled.

Expected behavior:

This should type check. The type of evenSquares should be number[].

Actual behavior:

The type of evenSquares is deduced as (number|null)[], despite the null values being removed via the call to .filter.

temoncher commented 1 year ago

@infacto Actually we can make first example work with current state of things using some more complex typeguards Playground

type MyObj = { data?: string };
type MyArray = { list?: MyObj[] }[];
const myArray: MyArray = [];

const isNotNullish = <T>(input: T | null | undefined): input is T => input != null
const isNotEmpty = <T>(input: T[]) => input.length !== 0;

function both<I, O1 extends I>(
  predicate1: (input: I) => boolean,
  predicate2: (input: I) => input is O1,
): (input: I) => input is O1;
function both<I, O1 extends I>(
  predicate1: (input: I) => input is O1,
  predicate2: (input: O1) => boolean,
): (input: I) => input is O1;
function both<I, O1 extends I, O2 extends O1>(
  predicate1: (input: I) => input is O1,
  predicate2: (input: O1) => input is O2,
): (input: I) => input is O2;
function both<I>(
  predicate1: (input: I) => boolean,
  predicate2: (input: I) => boolean,
) {
  return (input: I) => predicate1(input) && predicate2(input)
}

type HasNonNullishPredicate<P extends string> = <T extends { [K in P]?: unknown }>(obj: T) => obj is T & { [K in P]: Exclude<T[K], undefined | null> };
function hasNonNullish<P extends string>(prop: P): HasNonNullishPredicate<P>;
function hasNonNullish(prop: string) {
  return (input: object) => prop in input && (input as any).prop != null; // this `as any` cast will be unnecessary in TSv4.9
}

const result = myArray
  .map((arr) => arr.list)
  .filter(both(isNotNullish, isNotEmpty))
  .map((arr) => arr
    .filter(hasNonNullish('data'))
    .map(obj => JSON.parse(obj.data))
  );
infacto commented 1 year ago

@temoncher Thanks for your input. Helpful stuff. Somehow I hadn't thought of the return value of the arrow function in the filter method. 🤦‍♂️ Only indirect. Yes, makes sense. ^^ But anyway, it would be great if TypeScript could just infer that for less and cleaner code.

infacto commented 1 year ago

Sorry if I disturb you. I'm facing this problem again, where infer type guard for filter function would be awesome. Just another contribution post or thinking out loud.

To the example above:

context.filter((obj): obj is Required<MyObj> => !!obj?.data)

This guards the objects to have all properties required. In this example, it's ok. But for more complex object with several (optional) properties should rather do that.

context.filter((obj): obj is MyObj & Required<Pick<MyObj, 'data'>> => !!obj?.data)

Pick only the tested properties. Otherwise you may obfuscate nullish properties. It's a really long johnny. Infer type would simply look like:

context.filter((obj) => !!obj?.data)

Now I craft many type guards to avoid such long complex type defs. I mean this is just a simple example. The real world code type def would be 2 miles of code horizontal scoll. I thinking of a chain guard-type function like rxjs pipes. But it's either exhausting or unsafe (e.g. simple cast from unknown by assuming types). I don't want to rule out that I'm thinking too complicated and that there is a much simpler way. I just want to fix strict type issues. In best case without blind casting.

robertmassaioli commented 1 year ago

@infacto in pretty sure that's what isPresentKey does from ts-is-present

mhmo91 commented 1 year ago

This is pretty annoying issue that happens quite often. Can we raise priority on this one?

manniL commented 1 year ago

Leaving https://github.com/total-typescript/ts-reset/ here as a drop-in "fix" until this lands in TS itself.

btoo commented 1 year ago

i hope anyone advocating for using is knows that's still inherently type unsafe

function isPresent<T>(input: null | undefined | T): input is T {
    return !input;
}

const arr = ["string", 8, null, undefined].filter(isPresent);

// expects ["string", 8]
// but is instead [null, undefined]
console.log(arr)

is is no different from as, so i still like using flatMap, especially with the benchmark in https://github.com/microsoft/TypeScript/issues/16069#issuecomment-899633677 revealing that (the JIT compiler is allowing that) the performance cost is smaller than linear

ptitjes commented 1 year ago

Hi all,

When will TypeScript's type inference be fixed to handle this simple example ?

// Types as `(o: string | undefined) => o is string`
const myGuard = (o: string | undefined): o is string => !o;

// Types as `(o: string | undefined) => boolean` but should type as `(o: string | undefined) => o is string`
const mySecondGuard = (o: string | undefined) => myGuard(o);
JustinGrote commented 6 months ago

Thank you @danvk and @RyanCavanaugh!

ethanresnick commented 2 months ago

@ptitjes The issue with your example is that the first guard is not correct:

const myGuard = (o: string | undefined): o is string => !o;

const emptyString = '';

if(!myGuard(emptyString)) {
  // according to the "if and only if" nature of type predicates,
  // the fact that the predicate failed means that `emptyString` 
  // _must not_ be a string, but actually it is.
}
b-fett commented 2 months ago

i use such a patch:

diff --git a/node_modules/typescript/lib/lib.es5.d.ts b/node_modules/typescript/lib/lib.es5.d.ts
index a88d3b6..f091ecb 100644
--- a/node_modules/typescript/lib/lib.es5.d.ts
+++ b/node_modules/typescript/lib/lib.es5.d.ts
@@ -1268,6 +1268,7 @@ interface ReadonlyArray<T> {
      * @param thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
      */
     filter<S extends T>(predicate: (value: T, index: number, array: readonly T[]) => value is S, thisArg?: any): S[];
+    filter<F extends BooleanConstructor>(predicate: F, thisArg?: any): Exclude<T, false | null | undefined | '' | 0>[];
     /**
      * Returns the elements of an array that meet the condition specified in a callback function.
      * @param predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.
@@ -1459,6 +1460,7 @@ interface Array<T> {
      * @param thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
      */
     filter<S extends T>(predicate: (value: T, index: number, array: T[]) => value is S, thisArg?: any): S[];
+    filter<F extends BooleanConstructor>(predicate: F, thisArg?: any): Exclude<T, false | null | undefined | '' | 0>[];
     /**
      * Returns the elements of an array that meet the condition specified in a callback function.
      * @param predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.

with

[].filter(Boolean)