Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 914
universalhandle Avatar asked May 26 '20 22:05

universalhandle


People also ask

How does filter work in TypeScript?

In Typescript, Filter() is a built-in array method which is defined as a method for creating a new array or set of elements that contains a subset of the given array elements by returning the array of all the values of the elements in the newly created sub-array over the given array.

Does filter manipulate the array?

filter() does not modify the original array.

Does filter mutate the array?

filter() does not mutate the array on which it is called. The range of elements processed by filter() is set before the first invocation of callbackFn .

Why can't I call array filter() in typescript?

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.

What is typescript and how does typescript work?

TypeScript uses the best common type algorithm to select the best candidate types that are compatible with all variables. TypeScript also uses contextual typing to infer types of variables based on the locations of the variables.

How to get element type from array type in typescript?

To get th element type from an array type, use a condition type with an infer declaration to infer the type of an element in the array. TypeScript will fill in the type of the element, and we can return it in the true branch of the conditional type. Copied! type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[] ?

Can You narrow the type of an array in typescript?

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. See Also Way to tell TypeScript compiler Array.prototype.filter removes certain types from an array?


1 Answers

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

like image 171
jcalz Avatar answered Oct 16 '22 01:10

jcalz