Type Safety in JavaScript: Flow and TypeScript

Anyone who deals with UI development in a bloody enterprise has probably heard of "typed JavaScript", which means "Microsoft's TypeScript." But besides this solution, there is at least one more common JS typing system, and also from a major player in the IT world. This is a flow from Facebook. Because of my personal dislike for Microsoft, I used to always use flow. Objectively, this was explained by good integration with existing utilities and ease of transition.



Unfortunately, we must admit that in 2021 flow is already significantly inferior to TypeScript both in popularity and in support from a variety of utilities (and libraries), and it's time to bury it on the shelf and stop chewing cactus.go to the de facto TypeScript standard. But under this I would like to lastly compare these technologies, say a couple (or not a couple) of flow from Facebook.



Why do you need type safety in JavaScript?



JavaScript is a wonderful language. No not like this. The ecosystem built around JavaScript is great. For 2021, she really admires the fact that you can use the most modern features of the language, and then, by changing one setting of the build system, transpile the executable file in order to support its execution in older versions of browsers, including IE8, it will not be by night remember. You can "write in HTML" (meaning JSX), and then using the utility babel



(or tsc



) replace all tags with correct JavaScript constructs like calling the React library (or any other, but more on that in another post).



Why is JavaScript good as a scripting language that runs in your browser?



  • JavaScript does not need to be "compiled". You just add JavaScript constructs and the browser must understand them. This immediately gives a bunch of convenient and almost free things. For example, debugging directly in the browser, which is not the responsibility of the programmer (who must not forget, for example, to include a bunch of compiler debugging options and corresponding libraries), but the browser developer. You don't need to wait 10-30 minutes (real time for C / C ++) while your 10k-line project is compiled to try to write something differently. You just change the line, reload the browser page, and observe the new behavior of the code. And in the case of using, for example, webpack, the page will also be reloaded for you. Many browsers allow you to change the code right inside the page using their devtools.
  • - . 2021 . Chrome/Firefox, , , 5% (enterprise-) 30% (UI/) , .
  • JavaScript , . β€” ( worker'). , 100% CPU ( UI ), , , Promise/async/await/etc.
  • At the same time, I don't even consider the question of why JavaScript is important. After all, with the help of JS, you can: validate forms, update the content of the page without reloading it entirely, add non-standard behavior effects, work with audio and video, and you can even write the entire client of your enterprise application in JavaScript.


As with almost any scripting (interpreted) language, in JavaScript you can ... write broken code. If the browser doesn't reach this code, then there will be no error message, no warning, nothing at all. On the one hand, this is good. If you have a large, large website, then even a syntax error in the code of the button click handler should not result in the site not being loaded entirely by the user.



But, of course, this is bad. Because the very fact of having something not working somewhere on the site is bad. And it would be great, before the code gets to a working site, to check all-all scripts on the site and make sure that they at least compile. And ideally - and work. For this, a variety of sets of utilities are used (my favorite set is npm + webpack + babel / tsc + karma + jsdom + mocha + chai).



If we live in an ideal world, then all-all scripts on your site, even one-line ones, are covered with tests. But, unfortunately, the world is not perfect, and for all that part of the code that is not covered by tests, we can only rely on some kind of automated verification tools. Which can check:



  • JavaScript. , JavaScript, , , . /// .
  • . , , . , :



    var x = null;
    x.foo();
    
          
          





    . β€” null .


In addition to semantic errors, there can be even more terrible errors: logical errors. When the program runs without errors, but the result is not at all what was expected. Classic with addition of strings and numbers:



console.log( input.value ) // 1
console.log( input.value + 1 ) // 11

      
      





Existing static code analysis tools (eslint, for example) can try to track down a significant number of potential errors that a programmer makes in his code. For example:





Note that all of these rules are essentially constraints that the linter places on the programmer. That is, the linter actually reduces the capabilities of the JavaScript language so that the programmer makes fewer potential mistakes. If you enable all-all rules, then it will be impossible to make assignments in conditions (although JavaScript initially allows this), use duplicate keys in object literals, and even cannot be called console.log()



.



Adding variable types and type-aware call checking are additional limitations of the JavaScript language to reduce potential errors.



image

Trying to multiply a number by a string



An attempt to access a non-existent (not described in the type) property of an object

An attempt to access a non-existent (not described in the type) property of an object.



An attempt to access a non-existent (not described in the type) property of an object

An attempt to call a function with a mismatching argument type.



If we write this code without a type checker, the code is successfully transpiled. No means of static code analysis, if they do not use (explicitly or implicitly) information about the types of objects, will not be able to find these errors.



That is, adding typing to JavaScript adds additional restrictions to the code that the programmer writes, but it allows you to find errors that would otherwise occur during script execution (that is, most likely in the user's browser).



JavaScript typing capabilities



Flow TypeScript
Ability to set the type of a variable, argument, or return type of a function
a : number = 5;
function foo( bar : string) : void {
    /*...*/
} 

      
      



Ability to describe your object type (interface)
type MyType {
    foo: string,
    bar: number
}

      
      



Restricting Values ​​for a Type
type Suit = "Diamonds" | "Clubs" | "Hearts" | "Spades";

      
      



Separate type-level extension for enumerations
enum Direction { Up, Down, Left, Right }

      
      



"Adding" types
type MyType = TypeA & TypeB;

      
      



Additional "types" for complex cases
$Keys<T>, $Values<T>, $ReadOnly<T>, $Exact<T>, $Diff<A, B>, $Rest<A, B>, $PropertyType<T, k>, $ElementType<T, K>, $NonMaybeType<T>, $ObjMap<T, F>, $ObjMapi<T, F>, $TupleMap<T, F>, $Call<F, T...>, Class<T>, $Shape<T>, $Exports<T>, $Supertype<T>, $Subtype<T>, Existential Type (*)
      
      



Partial<T>, Required<T>, Readonly<T>, Record<K,T>, Pick<T, K>, Omit<T, K>, Exclude<T, U>, Extract<T, U>, NonNullable<T>, Parameters<T>, ConstructorParameters<T>, ReturnType<T>, InstanceType<T>, ThisParameterType<T>, OmitThisParameter<T>, ThisType<T>

      
      





Both engines for JavaScript type support have roughly the same capabilities. However, if you come from strongly typed languages, even typed JavaScript has a very important difference from Java: all types essentially describe interfaces, that is, a list of properties (and their types and / or arguments). And if two interfaces describe the same (or compatible) properties, then they can be used instead of each other. That is, the following code is correct in typed JavaScript, but clearly incorrect in Java, or, say, C ++:



type MyTypeA = { foo: string; bar: number; }
type MyTypeB = { foo: string; }

function myFunction( arg : MyTypeB ) : string {
    return `Hello, ${arg.foo}!`;
}

const myVar : MyTypeA = { foo: "World", bar: 42 } as MyTypeA;
console.log( myFunction( myVar ) ); // "Hello, World!"

      
      





This code is correct from the point of view of typed JavaScript, since the MyTypeB interface requires a property foo



with a type string



, while a variable with the MyTypeA interface does.



This code can be rewritten a little shorter, using a literal interface for a variable myVar



.



type MyTypeB = { foo: string; }

function myFunction( arg : MyTypeB ) : string {
    return `Hello, ${arg.foo}!`;
}

const myVar = { foo: "World", bar: 42 };
console.log( myFunction( myVar ) ); // "Hello, World!"

      
      





The variable type myVar



in this example is a literal interface { foo: string, bar: number }



. It is still compatible with the expected interface of a arg



function argument myFunction



, so this code is error-free from the point of view of, for example, TypeScript.



This behavior significantly reduces the number of problems when working with different libraries, custom code, and even just calling functions. A typical example is when some library defines valid options, and we pass them as an options object:



// -  
interface OptionsType {
    optionA?: string;
    optionB?: number;
}
export function libFunction( arg: number, options = {} as OptionsType) { /*...*/ }

      
      





//   
import {libFunction} from "lib";
libFunction( 42, { optionA: "someValue" } );

      
      





Note that the type is OptionsType



not exported from the library (nor is it imported into custom code). But this does not prevent you from calling the function using the literal interface for the second argument of the options



function, and for the typing system - to check this argument for type compatibility. Trying to do something like this in Java will cause a clear confusion among the compiler.



How does this work from a browser perspective?



Neither Microsoft's TypeScript nor Facebook's flow is supported by browsers. As well as the newest JavaScript language extensions have not yet found support in some browsers. So how is this code, firstly, checked for correctness, and secondly, how is it executed by the browser?



The answer is traspiling. All "non-standard" JavaScript code goes through a set of utilities that turn the "non-standard" (unknown to browsers) code into a set of instructions that browsers understand. And for typing, the whole "transformation" is that all type refinements, all interface descriptions, all restrictions from the code are simply removed. For example, the code from the example above turns into ...



/* : type MyTypeA = { foo: string; bar: number; } */
/* : type MyTypeB = { foo: string; } */

function myFunction( arg /* : : MyTypeB */ ) /* : : string */ {
    return `Hello, ${arg.foo}!`;
}

const myVar /* : : MyTypeA */ = { foo: "World", bar: 42 } /* : as MyTypeA */;
console.log( myFunction( myVar ) ); // "Hello, World!"

      
      





those.

function myFunction( arg ) {
    return `Hello, ${arg.foo}!`;
}
const myVar = { foo: "World", bar: 42 };
console.log( myFunction( myVar ) ); // "Hello, World!"

      
      





This conversion is usually done in one of the following ways.





Examples of project settings for flow and for TypeScript (using tsc).

Flow TypeScript
webpack.config.js
{
  test: /\.js$/,
  include: /src/,
  exclude: /node_modules/,
  loader: 'babel-loader',
},

      
      



{
  test: /\.(js|ts|tsx)$/,
  exclude: /node_modules/,
  include: /src/,
  loader: 'ts-loader',
},

      
      



Transpiler settings
babel.config.js tsconfig.json
module.exports = function( api ) {
  return {
    presets: [
      '@babel/preset-flow',
      '@babel/preset-env',
      '@babel/preset-react',
    ],
  };
};

      
      



{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": false,
    "jsx": "react",
    "lib": ["dom", "es5", "es6"],
    "module": "es2020",
    "moduleResolution": "node",
    "noImplicitAny": false,
    "outDir": "./dist/static",
    "target": "es6"
  },
  "include": ["src/**/*.ts*"],
  "exclude": ["node_modules", "**/*.spec.ts"]
}

      
      



.flowconfig
[ignore]
<PROJECT_ROOT>/dist/.*
<PROJECT_ROOT>/test/.*
[lints]
untyped-import=off
unclear-type=off
[options]

      
      





The difference between babel + strip and tsc approaches is small in terms of assembly. In the first case, babel is used, in the second, it will be tsc.





But it makes a difference if a utility like eslint is used. TypeScript for linting with eslint has its own set of plugins to help you find even more bugs. But they require that at the time of analysis by the linter it has information about the types of variables. To do this, only tsc should be used as a code parser, not babel. But if tsc is used for the linter, then it will be wrong to use babel for building (the zoo of utilities used should be minimal!).





Flow TypeScript
.eslint.js
module.exports = {
  parser: 'babel-eslint',
  parserOptions: {
    /* ... */

      
      



module.exports = {
  parser: '@typescript-eslint/parser',
  parserOptions: {
    /* ... */

      
      





Types for libraries



When a library is published to the npm repository, it is the JavaScript version that is published. It is assumed that the published code does not need to undergo additional transformations in order to use it in the project. That is, the code has already passed the necessary traspilation via babel or tsc. But then the information about the types in the code is already lost. What to do?



In flow, it is assumed that in addition to the "pure" JavaScript version, the library will contain files with the extension .js.flow



containing the source flow code with all type definitions. Then, when analyzing the flow, it will be able to connect these files for type checking, and when building the project and its execution, they will be ignored - ordinary JS files will be used. You can add .flow files to the library by simple copying. However, this will significantly increase the size of the library in npm.



In TypeScript, it is not suggested to keep the source files side by side, but only a list of definitions. If there is a file myModule.js



, then when analyzing the project, TypeScript will look for a file nearby myModule.js.d.ts



, in which it expects to see definitions (but not code!) Of all types, functions, and other things that are needed to analyze types. The tsc transpiler is able to create such files from the source TypeScript on its own (see the option declaration



in the documentation).



Types for legacy libraries



For both flow and TypeScript, there is a way to add type declarations for those libraries that do not initially contain these descriptions. But it is done in different ways.



For flow, there is no β€œnative” method supported by Facebook itself. But there is a flow-typed project that collects such definitions in its repository. In fact, a parallel way for npm to version such definitions, as well as a not very convenient "centralized" way of updating.



In TypeScript, the standard way of writing such definitions is to publish them in special npm packages with the "@types" prefix... In order to add a description of types for a library to your project, it is enough to connect the corresponding @types-library, for example, @types/react



for React or @types/chai



for chai.



Comparison of flow and TypeScript



An attempt to compare flow and TypeScript. Selected facts are collected from Nathan Sebhastian's article "TypeScript VS Flow", some are collected independently.



Native support across various frameworks. Native - no additional approach with a soldering iron and third-party libraries and plugins.



Various rulers

Flow TypeScript
Main contributor Facebook Microsoft
Website flow.org www.typescriptlang.org
Github github.com/facebook/flow github.com/microsoft/TypeScript
GitHub Starts 21.3k 70.1k
GitHub Forks 1.8k 9.2k
GitHub Issues: open / closed 2.4k / 4.1k 4.9k / 25.0k
StackOverflow Active 2289 146,221
StackOverflow Frequent 123 11451


Looking at these figures, I simply do not have the moral right to recommend flow for use. But why did I use it myself? Because there used to be such a thing as flow-runtime.



flow-runtime



flow-runtime is a set of plugins for babel that allows you to embed flow types into runtime, use them to define variable types at runtime, and, most importantly for me, allow you to check the types of variables at runtime. That allowed at runtime during, for example, autotests or manual testing, to catch additional bugs in the application.



That is, right at runtime (in the debug assembly, of course), the application explicitly checked all types of variables, arguments, results of calls to third-party functions, and everything, everything, everything, for compliance with those types.



Unfortunately, for the new year 2021, the author of the repository added informationthat he is no longer involved in the development of this project and, in general, switches to TypeScript. In fact, the last reason to stay on flow became deprecated for me. Well, welcome to TypeScript.



All Articles