Chapter 10: Rewrite a Full Code Base from JavaScript to TypeScript

In this chapter, we'll learn how TypeScript development experience is fascinating and how it makes a plain language such as JavaScript a language for every use case. We will also learn how Rematch takes TypeScript static types and makes a complete typed system, giving us the confidence to refactor our applications or websites easily. We'll discover how TypeScript makes the development experience of Rematch and React a pleasure and how easy it is to introduce business logic into our Rematch views using the typing system.

In this chapter, we'll cover the following topics:

  • Introduction to TypeScript
  • Rematch utility types
  • Converting Rematch models to TypeScript
  • TypeScript with React and Rematch

By the end of the chapter, you will understand how Rematch with TypeScript is one of the best decisions we can make when building a new product and how Rematch exposes some interesting utility types for archiving a complete typed code base.

Technical requirements

To follow along with this chapter, you will need the following:

  • Basic knowledge of Vanilla JavaScript and ES6 features
  • Basic knowledge of TypeScript
  • Basic knowledge of HTML5 features
  • Node.js 12 or later installed
  • Basic knowledge of React and CSS
  • A browser (Chrome or Firefox, for instance)
  • A code editor (Visual Studio Code, for instance)

You can find the code for this chapter in the book's GitHub repo: https://github.com/PacktPublishing/Redux-Made-Easy-with-Rematch/tree/main/packages/chapter-10.

This chapter assumes that you know a bit about TypeScript, and that you're familiar with the TypeScript ecosystem and which problems it tries to solve. But just in case, we'll do a brief introduction to what TypeScript is, along with the how and why.

Introduction to TypeScript

TypeScript is an open source language that builds on JavaScript, meaning all valid JavaScript code is also TypeScript code. It allows us to write types of objects, variables, or functions, providing better documentation and allowing TypeScript to validate the fact that our code is working as expected, but writing types is purely optional since TypeScript has type inference and, most of the time, TypeScript will know which type a constant is by its value.

When we're using plain JavaScript and we're developing a website with a lot of variables, sometimes, we try to access methods of our variables that don't exist.

For instance, when accessing .toLowerCase() of a number variable:

const value = 10_000

console.log(value.toLowerCase())

This code in our development editor won't log an error, or we won't see any change in our code. But let's say we try to run this code:

> value.toLowerCase()

Uncaught TypeError: value.toLowerCase is not a function

When JavaScript is executed, it returns an uncaught type error, an error that didn't appear while we were writing our code in our development editor but it failed on execution. TypeScript solves this inference problem with a typing system and, of course, an improved inference system giving us some IntelliSense about which functions, accessors, and setters exist in our variables.

This file, instead of being just a JavaScript file, is a TypeScript file, with the extension basically renamed from to .js to .ts:

Figure 10.1 – Visual Studio Code showing TypeScript errors

TypeScript is warning us that the .toLowerCase() property doesn't exist on type 10000, because value is a number, and .toLowerCase() method doesn't exist on number values. It knows that it doesn't exist because it automatically infers the value defined. If quotation marks are inserted around the 10_000 value, it will know that it is a string.

But what about complex data structures? How does it know when we receive data coming from an API response? That is easy; just type the return type of the function:

type ComplexStructure = { image_url: string }

async function simulatedApiResponse(): Promise<ComplexStructure> {

  return {

    image_url: ""

  }

}

This use case is a common scenario where we submit requests to a backend service and a response is returned. Since TypeScript is not evaluated on runtime, which is basically a static type checker, this makes it impossible to infer which data will be returned from that request, which is why we need to sometimes use the types that TypeScript offers.

TypeScript documentation is a masterpiece in terms of how something should be documented and oriented, which is why I encourage you to read https://www.typescriptlang.org, you will see a lot of examples explaining how TypeScript works and which types you can use.

But before starting on Rematch utility types, I want to introduce a subsection of TypeScript: generics.

Generics

TypeScript generics are one of the most important features of Rematch because this typing system is really based on it and its utility types expect to use them.

Basically, we can create types that accept any type of types – that's what generic means, right?

Let's look at an example. We create a function, called identity(arg), which could be multiple things. The easiest way is to type it like this:

function identity(arg: any): any {

  return arg;

}

This will work and is certainly generic, but it will cause the function to accept all types for the arg argument, losing all the information about what the type was when the function returns.

Instead, we need a way to capture the type of argument – this is where TypeScript generics come in. We define a generic type, which will be tracked along with the static checking, and will determine which type it is:

function identity<Type>(arg: Type): Type {

  return arg;

}

identity("string")

identity(10)

And of course, it accepts any complex type, so you can pass an argument type to the function:

type ComplexStructure = { image_url: string }

identity<ComplexStructure>({ image_url: '' })

With these concepts clear, we can now jump into Rematch and the TypeScript ecosystem, where we will discover which utility types are exported from the Rematch library and how we can get autocomplete and inference of our Rematch's models and state.

Rematch utility types

Since version 2 of Rematch, which was released on February 1, 2020, it is fully compatible out of the box with TypeScript. There are some key concepts that are important to understand in terms of how Rematch makes it possible to type every corner of our state.

We will start with the first one we should create when creating a Rematch application with TypeScript.

RootModel

RootModel, or whatever you want to call this interface, is the main interface of our store. It's a TypeScript interface that stores all of our model's types:

import { Models } from '@rematch/core'

import { count } from './models/count'

export interface RootModel extends Models<RootModel> {

    count: typeof count

}

export const models: RootModel = { count }

We need to create a circular cycle where RootModel is injected as a generic type into the Models type because Rematch architecture and how it's designed makes it possible to access state and dispatch effects or reducers from other models. Therefore, we need to make each model of our store available to TypeScript in some way.

RootModel will be the main generic type for the incoming sections, which, given a scenario, will extract the desired values.

init<RootModel, ExtraModels>()

The Rematch init() function in TypeScript accepts two generics. This is because when we were designing the TypeScript API, we wanted to use a single RootModel argument and get the type info of each model inside the models config, but TypeScript has a design limitation related to partial type inference that made it impossible to merge RootModel and ExtraModels together. That's the reason why we use one type to define our local models and another to define the plugin ones.

To get started with the most basic method of Rematch, that is the init() function, we must understand what RootModel and ExtraModels are.

RootModel

The first type argument that the init() function accepts is the RootModel interface we created previously. It is fundamental to get a fully typed store since it returns a Rematch store instance completely typed. Even Rematch plugins are fully typed:

import { init } from '@rematch/core'

import { RootModel, models } from './models'

export const store = init<RootModel>({

  models,

})

This code snippet is a demonstration of how you can pass the RootModel generic to the init() function, which will automatically type the store returned.

ExtraModels

The second argument is optional and, as the name indicates, is used to pass any additional models that may be injected through plugins such as @rematch/loading or @rematch/updated:

import { init } from "@rematch/core";

import createLoadingPlugin, { ExtraModelsFromLoading } from "@rematch/loading";

import { shop, cart, RootModel } from "./models";

type FullModel = ExtraModelsFromLoading<RootModel>;

export const store = init<RootModel, FullModel>({

  models: { shop, cart },

  plugins: [

    createLoadingPlugin(),

  ],

});

The Rematch loading and Rematch updated plugins use named exports to export ExtraModelsFromLoading and ExtraModelsFromUpdates, respectively. To pass the types generated by this utility type to the init() function, we need to do this because loading and updated plugins create models dynamically when the plugin initializes and their state is accessible, in the same way as our standard models.

We can extend the FullModel type as much as we can. Imagine we have a scenario with loading and updated plugins together; we could just use the & operator:

type FullModel = ExtraModelsFromLoading<RootModel> & ExtraModelsFromUpdated<RootModel>;

export const store = init<RootModel, FullModel>({

Thanks to this, we can make Rematch plugins totally extensible thanks to TypeScript modularity. Any plugin can extend this utility type and any application with Rematch could use them.

createModel

createModel is the utility type responsible for typing and inferring our Rematch models correctly:

import { createModel } from "@rematch/core";

Basically, this is a utility type for achieving the automatic inferring of some parameters that Rematch models expose, such as the root state, which will be automatically inferred and we don't need to type it.

Let's now check how this works with the help of a simple example:

export const shop = {

  state: {},

  reducers: {},

  effects: (dispatch) => ({})

}

This is just an empty Rematch model. Let's now introduce the createModel() utility type:

export const shop = createModel<RootModel>()({

  state: {},

  reducers: {},

  effects: (dispatch) => ({})

})

We just need to pass the RootModel type to the createModel generic type, and then use the currying workaround. Currying basically splits a single generic function of two type parameters into two curried functions of one type of parameter each:

Figure 10.2 – Function currying technique

In our case the first function argument is empty and is used for specifying the generic type, and the other one is used to pass the Rematch model that will be auto inferred by TypeScript.

This is a really common problem in these kinds of scenarios since it is a TypeScript design limitation that doesn't support partial type parameter inference, at least for now. That is the reason for using double parentheses – because we can't specify internally in Rematch which parameters should be inferred by TypeScript and which ones should be taken from the RootModel type.

Automatically, since we add the RootModel generic to the createModel function, every state and rootState parameter of our reducers and effects will be inferred, and also the dispatch function passed through the first argument of the effects property will be typed with all our methods that we can dispatch.

However, you may be asking yourself, how do we type our state when it contains complex structures, such as arrays, with complex objects inside? We can do two things:

  • Use the as keyword: TypeScript accepts writing the as keyword to tell the compiler to consider the object as a type other than the type the compiler infers the object to be (this is called Type Assertion):

    type ShopState = {

      products: Array<ProductType>;

      currentPage: number;

      totalCount: number;

      query: string | boolean;

    };

    export const shop = createModel<RootModel>()({

      state: {

        products: [],

        currentPage: 1,

        totalCount: 0,

        query: "",

      } as ShopState,

  • Create your state object outside of the createModel function and type it like a common object: Automatically, TypeScript and Rematch will know how to infer that object:

    type ShopState = {

      products: Array<ProductType>;

      currentPage: number;

      totalCount: number;

      query: string | boolean;

    };

    const SHOP_STATE_TYPED: ShopState = {

      products: [],

      currentPage: 1,

      totalCount: 0,

      query: "",

    };

    export const shop = createModel<RootModel>()({

      state: SHOP_STATE_TYPED,

Personally, I prefer the second choice a bit more than the first one, since the SHOP_STATE_TYPED constant could be exported for using it inside tests, and feels more readable to me, but any of these choices are valid and will work perfectly fine for Rematch and TypeScript.

RematchRootState

The RematchRootState<RootModel> utility type is useful for getting the IntelliSense of each model's state.

We'll use it when we have to type some Redux hooks such as useDispatch, or custom functions where we pass the entire root state and they are external to Rematch.

For example, given a function that filters by name and where the first parameter is RematchRootState, we can type it as follows:

export const filterByName = (

  rootState: RematchRootState<RootModel>,

  query: string

) =>

  rootState.shop.products.filter((product) =>

    product.productName.toLowerCase().includes(

    query.toLowerCase())

  );

By typing the rootState parameter, we'll get automatic IntelliSense of which models and which state values we can access, and also whether they may be undefined or null values, and even whether they're complex structures. Here, in this example where products are a complex type, we get the definition of our state type.

Figure 10.3 – Visual Studio Code IntelliSense of the RematchRootState utility type

In the internal typing system that makes this amazing experience possible, the RematchRootState utility type is heavily used, so it's safe to use it since it is battle-hardened.

RematchDispatch

RematchDispatch<RootModel> works in the same way as RematchRootState<RootModel>. Basically, they accept as generic the RootModel interface we named previously.

The principal function of RematchDispatch<RootModel> is to return which reducers and effects are available to dispatch, and the most important benefit is that RematchDispatch also types the payload we pass through the dispatch function, so basically, if we pass something that is incorrect, our TypeScript checker will warn us before our code runs at runtime.

Remember that we created a plugin in Chapter 9, Composable Plugins - Create Your First Plugin, with a similar feature, but for runtime instead of static-type checking as TypeScript does.

For example, we typed that the ADD_TO_CART reducer should receive at least the id property, which is a string and can't be null or undefined:

Figure 10.4 – Visual Studio Code showing errors when trying to dispatch incorrect values

Automatically, we're seeing errors when we try to use the dispatch function with the wrong payload. This saves a lot of potential bugs and is one of the biggest benefits of using TypeScript. We get notified before our code runs that this code probably won't work correctly.

In the next section, we're going to put all this theory into practice by migrating our Amazhop application to TypeScript. This task sometimes takes a few months for big companies to do, but I'll give some hints on how to do it easily and gradually.

Converting Rematch models to TypeScript

To get started on migrating our project to TypeScript, we'll need some steps that will be common for any project:

  • Create a configuration file for TypeScript.
  • Rename files to TypeScript files.
  • Installing declaration files and TypeScript dev dependency.

These steps will largely be required on all the projects you create with TypeScript, even without using Rematch. In this chapter, we won't explain every type we need to add to make the migration complete since it would become larger than desired. You can look at the result in the GitHub repository of the book, and you could also read the official TypeScript documentation regarding how to migrate from a JavaScript code base: https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html.

Creating the configuration file

Let's get started by creating a configuration file. We just need to create a tsconfig.json file in the root of our project with the following content:

{

  "compilerOptions": {

    "target": "ESNext",

    "module": "ESNext",

    "lib": ["DOM", "DOM.Iterable", "ESNext"],

    "moduleResolution": "Node",

    "skipLibCheck": true,

    "esModuleInterop": true,

    "strict": true,

    "resolveJsonModule": true,

    "isolatedModules": true,

    "noEmit": true,

    "jsx": "react"

  },

  "include": ["./src", "./test"]

}

This is a configuration file for TypeScript. It's the most common tsconfig.json file when using TypeScript with Vite. You can take a look at the official TypeScript documentation to know more about each property:

https://www.typescriptlang.org/docs/handbook/tsconfig-json.html.

Renaming our files

To rename our files, we could just rename them manually, file by file, but sometimes, on larger projects, this task becomes tedious, so you can use this shell script instead:

find src/ test/ -type f ( -iname '*.js' -or -iname '*.jsx' ) -not -wholename '*node_modules*' -exec sh -c 'mv "$1" `sed -Ee "s/.js(x)?$/.ts1/g" <<< "$1"`' _ {} ;

This script basically finds all the files inside the src/ and test/ folders that end with .js and rewrites them to .ts. The React JavaScript files, .jsx, are replaced by TypeScript React files, .tsx.

With these steps complete, we have already achieved a lot of early benefits, since our code editor is already displaying some code completion. TypeScript is probably already preventing us from realizing some bugs, such as forgetting to return at the end of a function or warning us about unreachable code or switch cases without the default case.

Installing declaration files

To get officially started with TypeScript in our code base, we need to install TypeScript as a development dependency. It isn't a dependency because TypeScript doesn't include any change in our bundle size with the typing since it is removed when Vite bundles our application because TypeScript is just a static type checker:

yarn add --dev typescript

Many of our dependencies will include their types published in the bundle of the library (Rematch does that), but also many of them use the GitHub repository, https://github.com/DefinitelyTyped/DefinitelyTyped. To type these libraries, the most famous case is React, which ships these types through the @types/react and @types/react-dom packages, respectively.

To achieve autocompletion and the advantages afforded by TypeScript's super-powers, we must install the following packages as development dependencies:

yarn add --dev @types/react @types/react-dom

Now we're ready to migrate our Rematch models to TypeScript, as we saw previously in our Rematch utility types section. We will begin with the RootModel interface, which should contain all the Rematch models of our store.

Creating the RootModel interface

Modify the src/store/models/index.ts file and include this new interface:

import type { Models } from "@rematch/core";

import { shop } from "./shop";

import { cart } from "./cart";

export interface RootModel extends Models<RootModel> {

  shop: typeof shop;

  cart: typeof cart;

}

export { shop, cart };

The RootModel interface is the most frequently used type in development with Rematch since it contains all the shapes of our Rematch models and makes them available in other models.

Now, we can modify our models with the Rematch utility function called createModel.

Using createModel in a Rematch model

To get started with the createModel function, we can check that the state argument in our reducers isn't typed. Basically, TypeScript throws an error, reporting to us the fact that state property has an implicit any type. This is because we're not yet using the createModel function. It has the responsibility of auto-inferring and typing our Rematch models efficiently:

Figure 10.5 – Rematch model throwing TypeScript errors

The createModel function will automatically type these arguments, and will also type the dispatch argument in the effects property, and of course the rootState argument in effects.

This is going to be resolved easily. Let's start by importing the createModel utility type along with RootModel from our index.ts file:

import { createModel } from "@rematch/core";

import type { RootModel } from "./index";

Now, we must pass the object model to the curried function:

export const shop = createModel<RootModel>()({

Automatically, after this simple change, we won't get any errors pertaining to state. This is because, under the hood, createModel is typing these arguments.

As you can see in the following screenshot, our state argument is partially correctly inferred:

Figure 10.6 – Rematch createModel typing the state argument

However, there is still work to do if we want a fully typed model. As you can see, currentPage, totalCount, and query are correctly inferred since their values are simply primitives, but products is a complex array with a custom object. We can type this just by creating a new type and adding an as property to the state.

Let's start by creating the type for our product, since products is an array of products:

export type ProductType = {

  id: string;

  image_url: string;

  stock: number;

  price: number;

  productName: string;

  productDescription: string;

  favorite: boolean;

};

This is a simple type describing the structure of any product. If you're not familiar with TypeScript, basically, TypeScript types define the shape of objects or arrays with primitive types that come out of the box with TypeScript, such as string, boolean, or number.

Now, just create a new type called ShopState:

export type ShopState = {

  products: Array<ProductType>;

  currentPage: number;

  totalCount: number;

  query: string | boolean;

};

This state type will be used with the as keyword to describe the state's shape:

Figure 10.7 – The state argument inferred as ShopState instead of never[]

As you can check, automatically we're getting our state argument inferred thanks to the as operator, and any reducer we write, or any effect, will be auto-completed with this shape. If we try to modify, delete, or add any existing property, this will result in TypeScript failing to warn us about the fact that property x doesn't exist in ShopState.

Just with this, we're already much safer than we were initially with just JavaScript, and we're getting autocompletion and warnings about a lot of uncovered sections of our project, but there's still a hole in our effects and reducers: our payloads are not typed since they could be anything. Let's type them.

Making payloads fully typed

For example, taking this reducer of the shop state, SET_PRODUCTS expects an object payload with products and totalCount, and right now it's inferred as any, but what if we just type it to an exact thing:

SET_PRODUCTS(state, { products, totalCount } : {

  products: Array<ProductType>,

  totalCount: number

}) {

This is basically defining the fact that this payload argument will just accept a products array of ProductType, and totalCount as number. This becomes super useful when using Rematch in our views since we'll get an auto-completion of what the reducer expects, or the effect to receive, and, of course, warnings if we pass something that's not expected.

These techniques are common in every Rematch model. Give it a try and you will see how easy it is to modify our Rematch models to be compatible with TypeScript.

There's just one thing left before moving to React views: passing RootModel to our init() function. To do this, modify the src/store/index.ts file with these new types:

import createLoadingPlugin, { ExtraModelsFromLoading } from "@rematch/loading";

import { shop, cart, RootModel } from "./models";

type FullModel = ExtraModelsFromLoading<RootModel>;

export const store = init<RootModel, FullModel>({

Since @rematch/loading creates a new model dynamically, we must use the utility type, which exports the plugin. ExtraModelsFromLoading basically makes it available under the hood, loading the state in any model or view.

Personally, I like to export two types into this file, which are heavily used along with the project:

export type Dispatch = RematchDispatch<RootModel>;

export type RootState = RematchRootState<RootModel, FullModel>;

These are the utility types that Rematch exports, but with the arguments already filled since we're importing RootModel and FullModel into this file. If we need to use these types somewhere, we just need to import Dispatch and not RematchDispatch and RootModel independently.

Now, we're ready to move on to our React view. The react-redux library is compatible out of the box with Rematch and, of course, with TypeScript.

TypeScript with React and Rematch

Thanks to TypeScript, we're now able to know which state is accessible, possibly undefined, or even doesn't exist. We just need to tweak some of the functions that we were already using, such as useDispatch or useSelector.

Taking src/components/Cart as an example, let's check how Rematch makes it extremely easy to power our React views with TypeScript IntelliSense:

import type { RootState, Dispatch } from "../../store";

export const Cart = () => {

  const dispatch = useDispatch<Dispatch>();

  const quantityById = useSelector(

    (rootState: RootState) => rootState.cart.quantityById

  );

  const cartProducts = useSelector(store.select.cart.  getCartProducts);

  const totalPrice = useSelector(store.select.cart.total);

As we saw previously, TypeScript generics are important for Rematch and also for React and Redux, since we can pass our RematchDispatch type, exported as Dispatch, to the useDispatch hook. This gives us a complete typed dispatch method.

We can check this by trying to pass any argument to the RESTORE_CART reducer. TypeScript warns that this reducer doesn't accept any arguments:

Figure 10.8 – TypeScript warning with the Rematch TypeScript dispatcher

Also, we use the RootState type to type the useSelector rootState argument. If we try to access any value that doesn't exist, this selector will fail.

And incredibly, our Rematch selectors are automatically inferred under the hood thanks to the Rematch typing system. Automatically, the useSelector hook will know what the getCartProducts selector returns.

This makes it super easy to follow how our state flows in our views, and easy to refactor when we add, remove, or modify any new property that is required or optional. For instance, we automatically get an autocompletion of our array of ProductType:

Figure 10.9 – Visual Studio Code IntelliSense with Rematch TypeScript

Visual Studio Code automatically displays a popup of which keys are available to access, and if we try to access any that are not typed in our model, it will fail.

Introducing TypeScript to an existing project built with JavaScript can be a tedious task, but since TypeScript makes things easier by doing it gradually, we'll see even more adoption in the future with any JavaScript project created from the start with TypeScript since the development experience benefits from multiple advantages, such as spotting bugs early, predictability, readability, optional static typing, fast refactoring, and much more.

Since the v2 release of Rematch, as maintainers, we have largely focused our time investment on improving the TypeScript experience, and we ended with a library of less than 2 KB that offers an amazing development experience without any trade-offs.

Summary

In this chapter, we learned how to gradually adopt TypeScript with an existing code base built with React. We also learned how Rematch exports some utility types to make things easier and we reviewed how migrating a project to TypeScript gives us a lot of benefits that make the effort associated with migrating the project to TypeScript worthwhile.

In the next chapter, we are going to create a React Native application from scratch, which will be a shop application with a common data layer. This means that we're going to build an Amazhop application for Apple and Android that will share the data layer with the Amazhop website. Instead of writing two pieces of business logic, we're just going to share the Amazhop implementation through NPM modules.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.12.36.30