universalhandle
universalhandle

Reputation: 547

Why can't TypeScript infer type from filtered arrays?

Below is some sample code. TypeScript infers the type of validStudents as Students[]. It should be obvious to anyone reading the code that, because all invalid records were filtered out, validStudents can safely be considered to have a type of ValidStudents[].

interface Student {
    name: string;
    isValid: boolean;
}
type ValidStudent = Student & { isValid: true };

const students: Student[] = [
    {
        name: 'Jane Doe',
        isValid: true,
    },
    {
        name: "Robert'); DROP TABLE Students;--",
        isValid: false,
    }
];

const validStudents = students.filter(student => student.isValid);

function foo(students: ValidStudent[]) {
    console.log(students);
}

// the next line throws compile-time errors:
// Argument of type 'Student[]' is not assignable to parameter of type 'ValidStudent[]'.
//   Type 'Student' is not assignable to type 'ValidStudent'.
//     Type 'Student' is not assignable to type '{ isValid: true; }'.
//       Types of property 'isValid' are incompatible.
//         Type 'boolean' is not assignable to type 'true'.ts(2345)
foo(validStudents);

It's possible to make this code work by adding a type assertion:

const validStudents = students.filter(student => student.isValid) as ValidStudent[];

... but it feels a little hacky. (Or maybe I just trust the compiler more than I do myself!)

Is there a better way to handle this?

Upvotes: 34

Views: 10874

Answers (2)

Aamir
Aamir

Reputation: 111

TypeScript 4.4 introduced improved type inference for filtered arrays when using type guards.

interface User {
  id: number;
  name: string;
  isActive: boolean;
}

const users: User[] = [
  { id: 1, name: "Alice", isActive: true },
  { id: 2, name: "Bob", isActive: false },
  { id: 3, name: "Charlie", isActive: true },
];

const activeUsers = users.filter((user) => user.isActive);

// TypeScript 4.4 correctly infers the type of activeUsers as User[]

Upvotes: 0

jcalz
jcalz

Reputation: 327829

A few things are going on here.


The first (minor) issue is that, with your Student interface, the compiler will not treat checking the isValid property as a type guard:

const s = students[Math.random() < 0.5 ? 0 : 1];
if (s.isValid) {
    foo([s]); // error!
    //   ~
    // Type 'Student' is not assignable to type 'ValidStudent'.
}

The compiler is only able to narrow the type of an object when checking a property if the object's type is a discriminated union and you are checking its discriminant property. But the Student interface is not a union, discriminated or otherwise; its isValid property is of a union type, but Student itself is not.

Luckily, you can get a nearly equivalent discriminated union version of Student by pushing the union up to the top level:

interface BaseStudent {
    name: string;
}
interface ValidStudent extends BaseStudent {
    isValid: true;
}
interface InvalidStudent extends BaseStudent {
    isValid: false;
}
type Student = ValidStudent | InvalidStudent;

Now the compiler will be able to use control flow analysis to understand the above check:

if (s.isValid) {
    foo([s]); // okay
}

This change is not of vital importance, since fixing it does not suddenly make the compiler able to infer your filter() narrowing. But if it were possible to do this, you'd need to use something like a discriminated union instead of an interface with a union-valued property.


The major issue is that TypeScript does not propagate the results of control flow analysis inside a function implementation to the scope where the function is called.

function isValidStudentSad(student: Student) {
    return student.isValid;
}

if (isValidStudentSad(s)) {
    foo([s]); // error!
    //   ~
    // Type 'Student' is not assignable to type 'ValidStudent'.
}

Inside isValidStudentSad(), the compiler knows that student.isValid implies that student is a ValidStudent, but outside isValidStudentSad(), the compiler only knows that it returns a boolean with no implications on the type of the passed-in parameter.

One way to deal with this lack of inference is annotate such boolean-returning functions as a user-defined type guard function. The compiler can't infer it, but you can assert it:

function isValidStudentHappy(student: Student): student is ValidStudent {
    return student.isValid;
}
if (isValidStudentHappy(s)) {
    foo([s]); // okay
}

The return type of isValidStudentHappy is a type predicate, student is ValidStudent. And now the compiler will understand that isValidStudentHappy(s) has implications for the type of s.

Note that it has been suggested, at microsoft/TypeScript#16069, that perhaps the compiler should be able to infer a type predicate return type for the return value of student.isValid. But it's been open for a long time and I don't see any obvious sign of it being worked on, so for now we can't expect it to be implemented.

Also note that you can annotate arrow functions as user-defined type guards... the equivalent to isValidStudentHappy is:

const isValidStudentArrow = 
  (student: Student): student is Student => student.isValid;

We're almost there. If you annotate your callback to filter() as a user-defined type guard, a wonderful thing happens:

const validStudents = 
  students.filter((student: Student): student is ValidStudent => student.isValid);

foo(validStudents); // okay!

The call to foo() type checks! That's because the TypeScript standard library typings for Array.filter() were given a new call signature that will narrow arrays when the callback is a user-defined type guard. Hooray!


So this is about the best that the compiler can do for you. The lack of automatic inference of type guard functions means that at the end of the day you are still telling the compiler that the callback does the narrowing, and is not much safer than the type assertion you're using in the question. But it is a little safer, and maybe someday the type predicate will be inferred automatically.

Playground link to code

Upvotes: 29

Related Questions