26

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?

universalhandle
  • 467
  • 5
  • 10
  • It looks a bit ugly but I think what you're doing is the right way to go about it. – CertainPerformance May 26 '20 at 22:11
  • 4
    Why would TS be able to infer this? It just knows you're calling a `.filter` on the array, you could also specify `student => student.name.startsWith("A")` as the filter criteria. Or anything else that doesn't change the composition of the array, hence it cannot really narrow the type. Also, if you filter an empty array, your validity filter *still* produces an array of invalid students. And an array of valid students. – VLAZ May 26 '20 at 22:11
  • 1
    **See Also** [Way to tell TypeScript compiler Array.prototype.filter removes certain types from an array?](https://stackoverflow.com/q/43010737/1366033) – KyleMit Nov 25 '21 at 00:20

1 Answers1

28

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

jcalz
  • 264,269
  • 27
  • 359
  • 360