Introduction to TypeScript and guide for how to migrate your project from Flow to TypeScript.

This post is a WIP.

Table of Contents

Motivation

Why use TypeScript?

  • TypeScript adds static typing for JavaScript, a dynamically typed and interpreted language. This significantly improves the maintainability and quality of the code.
  • TypeScript has powerful integration with VS Code, which is the defacto editor for JavaScript application and is also created and maintained by Microsoft.
  • TypeScript is backed by a big company - Microsoft.
  • TypeScript is more popular than its competitors, e.g., Flow. According to the StackOverflow Survey 2019, TypeScript is the third most loved language, see insights from stackoverflow’s 2019 survey
  • TypeScript transpiles into to JavaScript because it’s a superset of JavaScript.

Migrating Project to TypeScript

Assuming the project used Flow.

Phase 1: Configure Project to use TypeScript

Update Loader

Option 1 - Delete .babelrc and babel dependencies

Why? TypeScript now supports transpiling JavaScript.

yarn remove eslint-loader
yarn remove babel-loader
yarn add --dev awesome-typescript-loader source-map-loader typings-for-css-modules-loader

Option 2 - Update babel loader

If you have a project that needs to support both JS and TS, update babel

rm .babelrc
touch babel.config.js
// babel.config.js

module.exports = function babelConfig(api) {
  api.cache(true);
  return {
    presets: ["@smartling/babel-preset-smartling", "@babel/preset-typescript"],
    plugins: ["@babel/plugin-transform-modules-commonjs"],
    env: {
      test: {
        plugins: ["@babel/plugin-transform-modules-commonjs"],
      },
    },
  };
};

Update Webpack Config to use new loader

Assuming we went with babel-loader:

/* eslint-disable @typescript-eslint/no-var-requires */
const path = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const webpack = require("webpack");

const isDevelopmentMode = process.env.NODE_ENV === "development";

module.exports = {
  bail: !isDevelopmentMode,
  cache: true,
  devtool: isDevelopmentMode ? "eval-source-map" : false,
  mode: isDevelopmentMode ? "development" : "production",
  module: {
    rules: [
      {
        test: /\.(t|j)sx?$/,
        exclude: /node_modules/,
        loader: "eslint-loader",
        enforce: "pre",
      },
      {
        test: /\.(t|j)sx?$/,
        exclude: /node_modules/,
        loader: "babel-loader",
        query: {
          cacheDirectory: true,
        },
      },
      {
        test: /\.(woff2?|ttf|eot|svg)(\?[\s\S]+)?$/,
        loader: "url-loader",
        options: {
          name: "[name]-[hash].[ext]",
        },
      },
    ],
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
    }),
    new MiniCssExtractPlugin({ filename: "editor.css", allChunks: true }),
  ],
  resolve: {
    extensions: [
      ".js",
      ".jsx",
      ".ts",
      ".tsx",
      ".d.ts",
      ".scss",
      ".json",
      ".css",
    ],
    modules: [
      path.resolve(__dirname, "../src"),
      path.resolve(__dirname, "../node_modules"),
    ],
  },
};

After updating the webpack config, build the project (or run webpack) to generate the .d.ts files for your css / sass / pcss. This will ensure that these modules can be imported into your TypeScript project.

For more on TypeScript + WebPack + Sass

Add packages

yarn add --dev typescript @types/react @types/react-dom @types/jest @types/enzyme @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-import-resolver-typescript

Add the TS version of all your library code

yarn add @types/deep-freeze --dev
yarn add @types/chai --dev
yarn add @types/classnames

etc etc

Optional: Add type-check script in package.json

scripts: {
 "type-check": "tsc"
}

Set up Lint

Add tsconfig.json:

{
   "compilerOptions": {
       // Target latest version of ECMAScript.
       "target": "esnext",

       // Search under node_modules for non-relative imports.
       "moduleResolution": "node",

       // Process & infer types from .js files.
       "allowJs": true,

       // Don't emit; allow Babel to transform files.
       "noEmit": true,

       // Enable strictest settings like strictNullChecks & noImplicitAny.
       "strict": false,

       // Disallow features that require cross-file information for emit.
       "isolatedModules": true,

       // Import non-ES modules as default imports.
       "esModuleInterop": true,

       "jsx": "react",

       "module": "esNext"

   },
   "include": [
       "src/**/*.ts",
       "src/**/*.tsx"
   ],
   "exclude": [
       "src/**/*.js"
   ]
}

Update eslintrc.js

//eslintrc.js
module.exports = {
  extends: [
    "plugin:@typescript-eslint/recommended",
    "eslint:recommended",
    "@smartling/eslint-config-smartling",
  ],
  parser: "@typescript-eslint/parser",
  plugins: ["import", "@typescript-eslint"],
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
    },
    sourceType: "module",
    useJSXTextNode: true,
    project: "./tsconfig.json",
  },
  rules: {
    "@typescript-eslint/explicit-function-return-type": "off",
    "@typescript-eslint/explicit-member-accessibility": "off",
    "@typescript-eslint/member-delimiter-style": [2],
    "import/extensions": [1, "never", { ts: "never", json: "always" }],
    "react/jsx-indent": [0],
    "react/jsx-indent-props": [0],
    indent: [0],
  },
  overrides: [
    {
      files: ["**/*.ts", "**/*.tsx"],
      rules: {
        semi: 1,
        "no-unused-vars": ["off"],
        "quote-props": ["error", "as-needed"],
      },
    },
  ],
  settings: {
    "import/resolver": {
      node: {
        extensions: [".js", ".jsx", ".ts", ".tsx"],
      },
      "eslint-import-resolver-typescript": true,
    },
    "import/parsers": {
      "@typescript-eslint/parser": [".ts", ".tsx"],
    },
    react: {
      version: "detect",
    },
  },
  env: {
    jest: true,
    browser: true,
  },
};

I suggest excluding plugin:@typescript-eslint/recommended for a big project at first to facilitate an incremental migration.

TypeScript VSCode Integration

Open VSCode setting (See more about setting up TSLint here and here)

{
    "eslint.autoFixOnSave": true,
    "javascript.validate.enable": false,
    "editor.minimap.enabled": false,
    "git.enableSmartCommit": true,
    "window.zoomLevel": 1,
    "workbench.activityBar.visible": true,
    "javascript.updateImportsOnFileMove.enabled": "always",
    "typescript.implementationsCodeLens.enabled": true,
    "editor.formatOnSave": true,
    "eslint.validate": [
        {
            "language": "javascript",
            "autoFix": true
        },
        {
            "language": "javascriptreact",
            "autoFix": true
        },
        {
            "language": "typescript",
            "autoFix": true
        },
        {
            "language": "typescriptreact",
            "autoFix": true
        }
    ]
}

Phase 2 TypeScriptify Flow Project

Update files

  1. Delete Flow-related files. This include, but are not limited to:

    • flow-typed folder
    • flowconfig
    • flow-typed in eslintignore
  2. Delete all instances of // @flow and update import. Also, delete all instances of // $FlowFixMe

  3. Change all .js -> .ts or .tsx. I wrote a Bash script

sudo touch migrate.sh
vim migrate.sh
#!/bin/bash

cd $1

for f in `find . -type f -name '*.js'`;
do
  git mv -- "$f" "${f%.js}.ts"
done

for f in `find . -type f -name '*.jsx'`;
do
  git mv -- "$f" "${f%.js}.tsx"
done

Then provide the directory that you want to migrate as first argument of and execute the script.

chmod +x migrate.sh
./migrate.sh ~/tinext-editor/src

⚠️ But be careful about this approach if you want to keep history of the file in git. If you simply delete the old file with a copy of the file with a different file name, all the commit history related to this file will be lost. See this and this for more on preserving history when renaming files in git. This happens somtimes when you are renaming a file and changing a substantial amount of the file in one commit. Basically you have to trick git into recognizing file is renamed and not treat it as a delete-file / create-file case by commiting immediately after changing the name of the file, then commiting a second time after changing the contents of the file.

When you are committing a lot of files at once, git gets confused about the file renaming and thinks that the js files were deleted and the ts files are new. This can make PR review very challenging. It might be worth renaming the ts files back to js files for the PR, then revert them back to ts.

Update Plain JS Code to TypeScript

Check out:

When in doubt, try things out in TypeScript Playground or repl

This is helpful if you are migrating from Flow to TypeScript: typescript-vs-flowtype

Once the project is configured to use TypeScript, there will be a ton of type errors that need to be resolved. This could be a daunting task for one person and could take many weeks, which is a huge problem for an active codebase to which many changes are constantly made while the migration is happening. Depending on the size and complexity of the project you want to migrate, it might be a good idea to conduct a hackathon with peer programming so multiple people can dedicate their time resolving all the errors together and get it done in a short period of time. The Live Share VS Code extension is a useful tool for peer programming.

Importing types

// Flow
import type { Type1, Type2 } from ./dir/to/path
import { type Type3 } from ./dir/to/path
// Typescript
import { Type1, Type2 } from ./dir/to/path

Basic Types in TypesScript

  • any
  • void
  • Primitives: boolean, number, string, null, undefined
  • Array
    • string[]
    • Array<string> (Generic. More on that later)
  • Tuple: [string, number]
  • Union: string | null | undefined
  • Unreachable: never

We can also use literals as types. For example:

type One = 1;
const one: One = 1;
const two: One = 2; // <- error

Typing Functions

In TypeScript, there are three ways to type a function.

// Flow
type Date = {
  toString: () => string,
  setTime: (time: number) => number,
};
// TypeScript
interface Date {
  toString(): string;
  setTime(time: number): number;
}

Typing Objects

In flow, we use type.

In TypeScript, use interface. Interface types offer more capabilities they are generally preferred to type aliases. For instance:

  • An interface can be named in an extends or implements clause, but a type alias for an object type literal cannot.
  • An interface can have multiple merged declarations, but a type alias for an object type literal cannot.

Maybe Types

Flow has Maybe Types

// Flow
function acceptsMaybeString(value: ?string) {
  // ...
}

In addition to the type in ?type, maybe types can also be null or void.

In TypeScript, explicit typing is preferred.

// TypeScript
function acceptsMaybeString(value: string | null) {
  // ...
}

Casting

// Flow

type User = {
  firstName: string,
  lastName: string,
  email: string,
};
const user = {
  firstName: "Jane",
  lastname: "Doe",
  email: "example@example.com",
  paidUser: true,
};
const user2 = {
  firstName: "Jane",
  lastname: "Doe",
};

const userAsUser: User = ((user: User): User);
const user2AsUser: User = ((user2: User): User); // Fails

The casting for user2AsUser fails with the following error from flow:

Cannot cast user2 to User because property email is missing in object literal but exists in User

In TypeScript, we can do something like this:

// TypeScript

interface User {
  firstName: string;
  lastName: string;
  email: string;
}

const user: User = {
  firstName: "Jane",
  lastName: "Jane Doe",
} as any as User;

Readonly property

In flow, Plus sign in front of property Means it’s read-only https://stackoverflow.com/questions/46338710/flow-type-what-does-the-symbol-mean-in-front-a-property

In Typescript, use the β€œreadonly” keyword https://mariusschulz.com/blog/read-only-properties-in-typescript

Inline

// Flow
function getUser (): { name: string, age: number }
// TypeScript
// Note the semicolon
function getUser(): { name: string; age: number };

Explicit

Flow has type alias

// Flow
type User = {
  name: string,
  age: number
}

function getUser (): User

TypeScript has interface

// TypeScript
interface User {
  name: string;
  age: number;
}

function getUser(): User;

Optional Type

// Flow
type User = {
  name: string,
  age: number,
  location?: string,
};
// TypeScript
interface User {
  name: string;
  age: number;
  location?: string;
}

Generics

Types can be parameterized. In TypeScript, we can create a generic type with type parameters, which are represented by an arbitary letter like T, in angle brackets.

In Flow

type MyList = {
  filter: (Array<*>) => Array<*>,
  head: (Array<*>) => *,
};

In TypeScript

interface List<T> {
  filter: T[] => T[];
  head: T[] => T;
}

type NumberList = List<number>
type StringList = List<string>

An interface can extend other interfaces as demonstrated in these more complex example.

interface Entry {
  name: string;
  id: string;
}

interface EntryWithData<T> extends Entry {
  data?: T[];
  lastUpdated?: Date;
}

const stuff: EntryWithData<number> = {
  data: [1, 2, 3], // TS error: name and id are required for EntryWithData
};
interface GenericIcfOrChunk<T> {
  readonly type: T;
  chunks?: Chunk[];
  text?: string | null;
}

interface Chunk extends GenericIcfOrChunk<number> {
  id?: number;
  group?: number;
}

interface GenericIcf<I> extends GenericIcfOrChunk<IcfType> {
  id: I;
  group: number;
}

export type IcfDoc = GenericIcf<0>;
export type IcfSegment = GenericIcf<number>;

For more on Generics in TypeScript See the TypeScript handbook.

TypeScript supports many Generic types like Record and ArrayLike. See lib.es5.d.ts for a complete listing.

Note, in Flow, here’s how you extend a type:

type PremiumUser {
    ...User,
    annualPlan: boolean,
    monthlyPlan: boolean
}

There is a gotcha with combining types like this in flow. Using object spread to combine types makes the combined types optional. Using $Exact when spreading properties solves that problem.

type PremiumUser {
    ...$Exact<User>,
    annualPlan: boolean,
    monthlyPlan: boolean
}

Class

In the previous section, we saw that both interface and class can be used to create generic types. But this begs the question: what’s the difference between interface and class?

In object oriented programming, a class is a blueprint with properties and methods from which we can create objects. An interface is a collection of properties and methods.

In that sense, class and interface are the same. TypeScript allows us to type with classes. If we were to change the interface keyword to class in the examples in the Generics section, the code would still work. So why do we need class and when do we use it?

In short, class gives us more capability than interface such as designating properties as private or public and adding a constructor. This blog post and the official docs provide some use cases of class. The class in TypeScript look almost identical to the JavaScript class in ES6. This is reasonable since TypeScript is a superset of JavaScript.

Array Types

In flow, we use Array<ObjectType>

In TypeScript, we use ObjectType[], which is a shorthand for Array<ObjectType>. Array is a generic type in TypeScript.

Enum

This section only deals with TypeScript since Flow does not support enum, rather, to get the same result as enum in Flow, you have to do something like this:

FilterTypes = {
  ALL: "ALL",
  COMPLETED: "COMPLETED",
  UNASSIGNED: "UNASSIGNED",
};

const currentFilter: $Keys<typeof FilterTypes> = FilterTypes.ALL;

Enum in TypeScript allow us to make a collection of constants as types. Some examples:

Merging Enums

Combining two enums

enum Mammal {
  DOG = "DOG",
  HORSE = "HORSE",
  HUMAN = "HUMAN",
}
enum Insect {
  ANT = "ANT",
  BEE = "BEE",
  FLY = "FLY",
}

const Animal = {
  ...Mammal,
  ...Insect,
};

type Animal = Mammal | Insect;

type animalCounts = { [key in ValueOf<typeof AnimalT>]: number };

πŸ’‘ ProTip: you can use the same name for the type and a value of merged enum

The naming convention for enums is to:

Use a singular name for most Enum types, but use a plural name for Enum types that are bit fields.

See Steve Faulkner’s deck about best practice for TypeScript

Also see: https://github.com/Microsoft/TypeScript/issues/17592

Here’s another Example:

const enum BasicEvents {
  Start = "Start",
  Finish = "Finish",
}
const enum AdvEvents {
  Pause = "Pause",
  Resume = "Resume",
}

type Events = BasicEvents | AdvEvents;

let e: Events = AdvEvents.Pause;
Check for membership
export const isMammal = (animal: Animal): animal is Animal =>
  Object.values(Mammal).includes(animal);
Subset of Enum
const animals: AnimalT[] = [β€œDOG”, β€œANT”, β€œHUMAN”, β€œBEE”]
const mammals: Mammal[] = animals.filter(animal => animal in Mammal)
console.log(mammals) //> [β€œDOG”, β€œHUMAN"]

Note the difference between Animal and AnimalT!

Type Assertion

Consider the case when you have a union type that could be one of the two interfaces.

interface A {
  a: string;
}
interface B {
  b: string;
}
type AorB = A | B;

We create the following objects:

const a: AorB = { a: "a" };
const b: AorB = { b: "b" };

If we want to access the property a from a, sometimes we need to assert type like so:

if ((<A>AorB).a)

See Type guards and type assertions section of Advanced Types.

Dictionary typing using enum
enum Var {
    X = "x",
    Y = "y",
}

type Dict = { [var in Var]: string };

This is a lot simpler than Flow. In Flow, we had to do something like this:

// Flow

const Vars = {
  X: "x",
  Y: "y"
}

type VarType = $Keys<typeof Vars>
type Dict = { [var in VarType]: string }

Exclude

enum Animal {
  LION = "LION",
  PIG = "PIG",
  COW = "COW",
  ANT = "ANT",
}

type DomesticatedMammals = {
  [animal in Exclude<Animal, Animal.ANT>]: boolean;
};

Using enums in Map

enum One {
  A = "A",
  B = "B",
  C = "C",
}

enum Two {
  D = "D",
  E = "E",
  F = "F",
}

const map = new Map<string, One | Two>([
  ["a", One.A],
  ["d", Two.D],
]);

Shape

$Shape<SomeObjectType> in Flow has an analog in TypeScript: Partial<SomeObjectType>.

Gotchas

Tuple Types

TypeScript sometimes does not recognize Tuple Types. Solution: explicit casting or typing when declaring new var

Example

type Interval = [number, number];

const getMaxAndMin = (interval: Interval) => ({
  min: interval[0],
  max: interval[1],
});

const interval = [0, 3];

getMaxAndMin(interval);

TypeScript complains:

Argument of type ’number[]’ is not assignable to parameter of type ‘[number, number]’. Type ’number[]’ is missing the following properties from type ‘[number, number]’

Solution:

const interval: Interval = [0, 3];

getMaxAndMin(interval);

Or

getMaxAndMin([0, 3]);

Enzyme Mount

class MyButton extends React.Component {
  constructor() {
    this.handleClickBound = handleClick.bind(this);
  }
  handleClick() {
    console.log("do something");
  }
  render() {
    return <button onClick={handleClickBound}>Click Me</button>;
  }
}
const button = mount(<MyButton />);
const buttonInstance = button.instance();
buttonInstance.handleClick();

Property ‘handleClick’ does not exist on type ‘Component<{}, {}, any>’

Solution:

const button = mount<MyButton>(<MyButton />);

Optional

CI/CD

  • Update your CI/CD pipeline to include type checking as part of the build process. For example
// Jenkinsfile
stage('Type Check') {
    sh 'yarn type-check'
}

Storybook

Easy set up

  1. Add Storybook for React

    npx -p @storybook/cli sb init --type react
    

    This create a .storybook directory at the root of your project. Alternatively, you can do everything manually.

    Add all the dependencies

    yarn add -d @babel/core @storybook/addon-actions @storybook/addon-links @storybook/addons @storybook/react babel-loader
    
    yarn add -D @storybook/react @storybook/addon-info @storybook/addon-jest @storybook/addon-knobs @storybook/addon-options @storybook/addons @storybook/react storybook-addon-jsx @types/react babel-core typescript awesome-typescript-loader react-docgen-typescript-webpack-plugin jest @types/jest ts-jest
    

    Create the files and folders

    mkdir .storybook
    touch .storybook/config.js .storybook/addons.js .storybook/webpack.config.js
    
  2. Add add-ons for Storybook

    yarn add -D @storybook/addon-storysource @storybook/addon-knobs storybook-addon-jsx @storybook/addon-a11y
    
  3. Add the dependencies for typescript loader:

    yarn add awesome-typescript-loader @storybook/addon-info react-docgen-typescript-loader
    
  4. In tsconfig.json, make sure compilerOptions has the following attribute:

    "jsx": "react"
    

    And make sure rootDir includes stories:

    "rootDirs": [
        "src", "stories"
    ],
    
  5. Update .storybook/config.js:

    import { configure } from "@storybook/react";
    import { setAddon, addDecorator } from "@storybook/react";
    import JSXAddon from "storybook-addon-jsx";
    import { withKnobs, select } from "@storybook/addon-knobs/react";
    
    addDecorator(withKnobs);
    setAddon(JSXAddon);
    
    // automatically import all files ending in *.stories.js
    const req = require.context("../stories", true, /.stories.(t|j)sx?$/);
    
    function loadStories() {
      req.keys().forEach((filename) => req(filename));
    }
    
    configure(loadStories, module);
    
  6. Add .storybook/webpack.config.js:

    module.exports = ({ config }) => {
      config.module.rules.push({
        test: /\.stories\.jsx?$/,
        use: [
          {
            loader: require.resolve("@storybook/addon-storysource/loader"),
          },
        ],
      });
      config.module.rules.push({
        test: /\.stories\.tsx?$/,
        use: [
          {
            loader: require.resolve("awesome-typescript-loader"),
          },
          // Optional
          {
            loader: require.resolve("react-docgen-typescript-loader"),
          },
        ],
      });
      config.resolve.extensions.push(".ts", ".tsx");
      return config;
    };
    

    This configuration gives us the ability to load both js and ts stories.

Guides and Resources

Phase 3 Regression Testing

  • Re-run unit tests to make sure they all pass
  • Integration testing - if the project is a library, make sure you can build it and integrate it into a project that’s not TypeScript.

TypeScript Learning Resource

TypeScript Learning Resources