Skip to content

Type Predicate Function Inconsistent Across Identical Inferred Types #49514

Closed

Description

@joealden

Bug Report

🔎 Search Terms

type predicate filter flatMap null

🕗 Version & Regression Information

This is the behavior in every version I tried (that don't throw errors), and I reviewed all FAQ entries.

⏯ Playground Link

Playground link with relevant code

💻 Code

import { F } from "ts-toolbelt"; // can easily be replaced with raw TS code if needed

const satisfies =
  <TWide,>() =>
  <TNarrow extends TWide>(narrow: F.Narrow<TNarrow>) =>
    narrow;

const isNotNull = <T,>(value: T | null): value is T => value !== null;

type Item = Array<{ value: string | null }>;

const item1 = satisfies<Item>()([{ value: "1" }]);
const item2 = satisfies<Item>()([{ value: "2" }]);
const item3 = satisfies<Item>()([{ value: null }]);

const values1 = [item1, item2, item3].flatMap(item => item.map(p => p.value));
const filteredValues1 = values1.filter(isNotNull); // still contains `null` in type :(

const values2: Array<"1" | "2" | null> = ["1", "2", null]; // same reported type as `values`
const filteredValues2 = values2.filter(isNotNull); // `null` correctly gets excluded from type

🙁 Actual behavior

filteredValues1 still contains null in its type when is shouldn't.

🙂 Expected behavior

filteredValues1 should have the same type as filteredValues2 (without null).


Originated from this Twitter thread: https://twitter.com/joealden_/status/1536335566891061249

Activity

  1. RyanCavanaugh commented on Jun 13, 2022

    @RyanCavanaugh
    Member

    A repro with an inlined F would be appreciated if anyone has time

  2. joealden commented on Jun 13, 2022

    @joealden
    Author

    A repro with an inlined F would be appreciated if anyone has time

    Ryan Cavanaugh (@RyanCavanaugh) here you go:

    Playground link with relevant code

    type Try<A1 extends any, A2 extends any, Catch = never> = A1 extends A2 ? A1 : Catch;
    
    type Narrowable = string | number | bigint | boolean;
    
    type NarrowRaw<A> = (A extends [] ? [] : never) | (A extends Narrowable ? A : never) | ({
        [K in keyof A]: A[K] extends Function ? A[K] : NarrowRaw<A[K]>;
    });
    
    type Narrow<A extends any> = Try<A, [], NarrowRaw<A>>;
    
    const satisfies =
      <TWide,>() =>
      <TNarrow extends TWide>(narrow: Narrow<TNarrow>) =>
        narrow;
    
    /* ========================================================================== */
    
    const isNotNull = <T,>(value: T | null): value is T => value !== null;
    
    type Item = Array<{ value: string | null }>;
    
    const item1 = satisfies<Item>()([{ value: "1" }]);
    const item2 = satisfies<Item>()([{ value: "2" }]);
    const item3 = satisfies<Item>()([{ value: null }]);
    
    const values1 = [item1, item2, item3].flatMap(item => item.map(p => p.value));
    const filteredValues1 = values1.filter(isNotNull); // still contains `null` in type :(
    
    const values2: Array<"1" | "2" | null> = ["1", "2", null]; // same reported type as `values`
    const filteredValues2 = values2.filter(isNotNull); // `null` correctly gets excluded from type
  3. JoanVicens commented on Jun 23, 2022

    @JoanVicens

    I'm having the same problem with the filter function filtering an array of any.
    What seems odd is that the same line inside a function works fine.

    Playground

    type NotNull = string | number | boolean | symbol | object;
    
    function isNotNull(value: any): value is NotNull {
      return value !== null;
    }
    
    function filterNulls(values: Array<any>): Array<NotNull> {
      return values.filter(isNotNull);
    }
    
    const values = ["( ㅅ )", 56, null, undefined, true, {name: "f"}];
    
    const notNull = filterNulls(values); // returns type: NotNull[]
    const canBeNull = values.filter(isNotNull); // returns type: (string | number | boolean | { name: string; } | null | undefined)[]
    const _ = values.filter<NotNull>(values); // Type 'NotNull' does not satisfy the constraint 'string | number | boolean | { name: string; } | null | undefined'.
    
    function filterNullsWithoutReturn(values: Array<any>): void {
      const notNulls = values.filter(isNotNull); // returns type: NotNull[]
    }
    `
  4. andrewbranch commented on Jul 8, 2022

    @andrewbranch
    Member

    Well, after a couple hours debugging, I know what’s causing this, but I’m not yet sure what to do about it. In the working values2 case, the "1" | "2" constituents of the union type are not “fresh literal” types. In values1, they are fresh, and so they get widened to string when we are instantiating the signature of isNotNull. (The inference candidate for its T is the union of literals, but at the last possible moment in getCovariantInference, it sees no reason not to widen.) Once we have inferred isNotNull<string>, we can no longer choose the type predicate overload for .filter because string is not a subtype of "1" | "2" | null, so the non-narrowing signature is chosen.

    Literal freshness is not a system I’m super familiar with in the compiler. Can anyone advise on how this is expected to work? (Wesley Wigham (@weswigham) Anders Hejlsberg (@ahejlsberg)?)

  5. ahejlsberg commented on Jul 13, 2022

    @ahejlsberg
    Member

    Here's a shorter example to illustrate the surprising effects of preserving literal freshness in array literal elements and object literal properties:

    declare function f1<T extends "1" | "2" | "3">(x: T): T;
    
    const value = f1("1");  // regular "1"
    let x1 = value;  // regular "1"
    
    declare function f2<T extends "1" | "2" | "3">(x: { value: T }): { value: T };
    
    const obj2 = f2({ value: "1" });  // { value: regular "1" }
    let x2 = obj2.value;  // regular "1"
    
    declare function f3<T extends { value: "1" | "2" | "3" }>(obj: T): T;
    
    const obj3 = f3({ value: "1" });  // { value: fresh "1" }
    let x3 = obj3.value;  // string

    The obj3 example definitely doesn't seem right, so I think it makes sense to always infer regular (non-fresh) literal types for array literal elements and object literal properties.

  6. locked as resolved and limited conversation to collaborators on Oct 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugA bug in TypeScriptFix AvailableA PR has been opened for this issue

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions