36 Flow Type Checking in Js Through Flow

36 Flow Type Checking in JS Through Flow #

Hello, I am Ishikawa.

Earlier, we discussed how quality and style checks can help us discover and avoid potential issues in our code, in addition to functional and non-functional testing. Today, let’s take a look at another method of discovering and avoiding potential problems – type checking our code. When it comes to type checking, TypeScript may be a more suitable language, but since this column primarily focuses on JavaScript, today we will learn about type checking in JavaScript using Flow, a JavaScript language extension.

If you have experience with C or Java development, you may be familiar with type annotations. Let’s take the classic “Hello World” example in C as an example. In this case, int represents an integer, which is the type of the function. However, in JavaScript, there is no requirement for type annotations. With Flow, we can add type annotations and also perform type checking on both annotated and unannotated code.

#include <stdio.h>
    
int main() {
  printf("Hello World! \n");
  return 0;
}

The working principle of Flow is simple and can be summarized in 3 steps:

  1. Add type annotations to the code.
  2. Run the Flow tool to analyze the code’s types and report any related errors.
  3. Once the issues are fixed, we can remove the type annotations from the code using Babel or other automated code bundling processes.

You may wonder why we remove the annotations in step 3. This is because the Flow language extension itself does not change the compilation or syntax of JavaScript. It is simply a static type checking tool used during the code writing phase.

Why Do We Need Types #

Before we talk about Flow, let’s first familiarize ourselves with the use cases and purposes of type checking. The main application scenario for type annotations and checks is the development of large and complex projects. In such scenarios, strict type checking can prevent potential issues caused by inconsistencies in types during code execution.

Additionally, let’s take a look at the similarities and differences between Flow and TypeScript. Here, we can have a brief understanding. First, let’s talk about the similarities:

  • Firstly, TypeScript itself is an extension of JavaScript, as its name suggests, it is a “typed scripting language”;
  • Secondly, the code annotation and checking process of TypeScript with the TSC compiler is similar to the pattern of Flow with Babel;
  • For simple type annotations, both tools are similar;
  • Finally, their application scenarios and purposes are also similar.

Now let’s look at the differences between Flow and TypeScript:

  • Firstly, in terms of relatively advanced types, the syntax of the two tools is different, but it is not difficult to achieve conversion;
  • Secondly, TypeScript was released before ES6 in 2012, so despite being named TypeScript, it added many features in order to compensate for the shortcomings of ES5 at that time, such as the lack of class, for/of loops, module system, and Promises;
  • In addition, TypeScript has also added many of its own unique keywords such as enum and namespace, so it can be considered as a separate language. Whereas, Flow is more lightweight as it is built on top of JavaScript, adding type checking functionality without creating a new language, although it does alter the JavaScript language itself.

Installation and Running #

After learning the basics of Flow, let’s now dive into the topic and see how to install and use Flow.

Similar to other tools we introduced in the JavaScript Toolbox, we can install Flow using NPM. Here, we also use the -g option, which allows us to run the related program through the command line.

npm install -g flow-bin

Before using Flow for code type checking, we need to initialize it in the project directory using the following command. Initialization creates a configuration file with the .flowconfig extension in the project’s location. Although we generally don’t need to modify this file, it allows Flow to know the location of our project.

npm run flow --init

After the initial setup, we can run subsequent checks directly using npm run flow.

npm run flow

Flow will find all JavaScript code in the project’s location, but it will only perform type checking on files that have // @flow at the top. The advantage of this approach is that for existing projects, we can gradually add type annotations to files one by one, according to a planned schedule.

Previously, we mentioned that even for code without annotations, Flow can still perform checks. For example, in the following example, because we did not declare i as a local variable inside the for loop, it may cause pollution to the global scope. Therefore, even without annotations, Flow will show an error. A simple solution is to add for (let i = 0) inside the for loop.

// @flow
let i = { x: 0, y: 1 };
for(i = 0; i < 10; i++) { 
  console.log(i);
}
i.x = 1;  

Similarly, in the following example, an error will be shown even without annotations. Although Flow initially doesn’t know the type of the msg parameter, upon seeing the length property, it knows that it cannot be a number. However, when a numeric argument is passed during the function call, it obviously causes a problem, leading to an error.

// @flow
function msgSize(msg) {
  return msg.length;
}
let message = msgSize(10000);

The Use of Type Annotations #

Previously, we talked about type checking without annotations. Now, let’s take a look at the use of type annotations. When declaring a JavaScript variable, you can add a Flow type annotation by using a colon and the type name. In the example below, we declare the types for number, string, and boolean.

// @flow
let num: number = 32;
let msg: string = "Hello world";
let flag: boolean = false;

Similar to the previous example without annotations, even without annotations, Flow can infer the types of values through variable assignments. The only difference is that with annotations, Flow compares the annotation with the assigned value’s type, and if there is a mismatch, it throws an error.

Type annotations for function parameters and return values are similar to variable annotations. In the example below, we annotate the parameter as a string type and the return value as a number type. When we run the check, although the function itself can return a result, an error occurs. This is because we expect the return value to be a string, but the result of the array length is a number.

// Regular function
function msgSize(msg: string): string {
  return msg.length;
}
console.log(msgSize([1,2,3]));

However, there is one thing to note: in JavaScript and Flow, the type of null is the same. But in Flow, undefined in JavaScript is void. Furthermore, for functions without return values, we can annotate them with void.

If you want to allow null or undefined as valid variable or parameter values, simply prefix the type with a question mark. In the example below, we use ?string. In this case, although it won’t throw an error for the type of null as a parameter itself, it reports an error stating that msg.length is unsafe. This is because msg may be null or undefined, which don’t have a length property. To resolve this error, we can use a conditional statement to only return msg.length if the condition is a truthy value.

// @flow

function msgSize(msg: ?string): number {
  return msg.length;
}
console.log(msgSize(null));

function msgSize(msg: ?string): number {
  return msg ? msg.length : -1;
}
console.log(msgSize(null));

Support for Complex Data Types #

So far, we have learned about several primitive data types: strings, numbers, booleans, and the checks for null and undefined. We have also learned about how Flow is used in variable declaration, assignment, function parameters, and return values. Now let’s take a look at how Flow supports checking for other more complex data types.

Classes #

First, let’s take a look at classes. The keyword class does not require any additional annotations, but we can provide type annotations for the properties and methods inside the class. For example, in the code snippet below, the prop property is annotated as a number. The method method takes a string parameter and its return value is defined as a number.

// @flow
class MyClass {
  prop: number = 42;
  method(value: string): number { /* ... */ }
}

Objects and Type Aliases #

In Flow, object types look similar to object literals, but the difference is that the property values in Flow are types.

// @flow
var obj1: { foo: boolean } = { foo: true };

In objects, if a property is optional, we can use a question mark instead of void and undefined. If a property is not annotated as optional, it is considered to be required. To make a property optional, we can add a question mark.

var obj: { foo?: boolean } = {};

Flow does not throw an error for additional properties in a function that are not explicitly annotated. If we want Flow to strictly enforce only the declared property types, we can use the pipe | symbol to declare the relevant object types.

// @flow
function method(obj: { foo: string }) {
  // ...
}
method({
  foo: "test", // passes
  bar: 42      // passes
});

{| foo: string, bar: number |}

For lengthy object type declarations, we can abstract the object type by using a custom type name.

// @flow
export type MyObject = {
  x: number,
  y: string,
};

export default function method(val: MyObject) {
  // ... 
}

We can export types similar to exporting modules. Other modules can reference the type definitions by importing them. However, it is important to note that importing types is an extension of the Flow language and not an actual JavaScript import directive. Type imports and exports are only used by Flow for type checking and are removed in the final executed code. Lastly, it is worth mentioning that it is more concise to directly define a MyObject class as a type instead of creating a type.

In JavaScript, objects are sometimes used as dictionaries or mappings from strings or other values to values. The property names are not known in advance and cannot be declared as a Flow type. However, we can still describe the data structure with Flow. Let’s say you have an object where the properties are city names and the values are the locations of the cities. We can declare the data type as shown below.

// @flow
var cityLocations : {[string]: {long:number, lat:number}} = {
    "上海": { long: 31.22222, lat: 121.45806 }
};
export default cityLocations;

Arrays #

The element types of arrays can be specified using angle brackets <>. An array with a fixed length and elements of different types is called a tuple. In a tuple, the types of the elements are declared in square brackets separated by commas.

We can use destructuring assignment and type aliases with Flow to work with tuples.

When we need a function to accept an array of arbitrary length as a parameter, we cannot use tuples. Instead, we use Array<mixed>. mixed represents that the elements of the array can be of any type.

// @flow

function average(data: Array<number>) {
 // ...
}

let tuple: [number, boolean, string] = [1, true, "three"];
let num  : number  = tuple[0]; // passes
let bool : boolean = tuple[1]; // passes
let str  : string  = tuple[2]; // passes

function size(s: Array<mixed>): number {
    return s.length;
}
console.log(size([1,true,"three"]));

If our function retrieves and uses elements from the array, Flow checks the types of the elements based on type checks or other tests. If you are willing to give up type checking, you can use any instead of mixed which allows you to do anything with the array values without ensuring that they are of the expected type.

Functions #

We have learned how to specify the parameter types and return type of a function by adding type annotations. However, in higher-order functions, where one of the function’s parameters is itself a function, we also need to be able to specify the type of that function parameter. To represent a function type in Flow, we need to write each parameter’s type separated by commas and enclosed in parentheses, followed by an arrow, and then the return type of the function.

Here is an example function that expects a callback function to be passed. Note how we define a type alias for the callback function’s type.

// @flow
type FetchTextCallback = (?Error, ?number, ?string) => void;
function fetchText(url: string, callback: FetchTextCallback) {
  // ...  
}

Summary #

Although using type checking requires a lot of extra work, and when you start using Flow, you may uncover a lot of issues, this is normal. Once you have mastered the type specifications, you will find that it can avoid many potential problems, such as inconsistencies between the input and output value types in functions and the expected parameters or results.

In addition to the core data types we introduced, such as numbers, strings, functions, objects, and arrays, type checking has many other applications. You can learn more through Flow’s official website.

Reflection Questions #

In type checking, there are two thoughts: soundness and completeness. Soundness checks for any possible problems that may occur at runtime, while completeness checks for problems that will definitely occur at runtime. The first thought is “better to err on the side of caution”; however, the problem with the latter is that sometimes it may let problems “escape”. So, do you know which thought Flow or TypeScript follows? Which thought do you think is more suitable for implementation?

Please feel free to share your thoughts, exchange learning experiences, or ask questions in the comments section. If you found this content helpful, you are also welcome to share it with more friends. See you in the next class!