Learn TypeScript with Redux Codebase

Updated May 22, 2021#typescript#redux#guides

Is it right to say writing TypeScript is just JavaScript with types? Probably yes in early stages, but later on TypeScript developers tend to use more powerful TypeScript-specific features like Generics, Declaration Merging, Function Overloads.

TypeScript has great documentation at typescriptlang.org, you’ll find anything you need there. This post only introduces those TypeScript-specific features by reading open-source project Redux — one of the most beautiful well-documented beginner-friendly TypeScript codebase.

Compiler Options

TypeScript compiler is extremely flexible with hundreds of compile options, copying recommended configs works just fine in development but might bite you in production. It’s really hard to test different options, the best you can do is fully understanding each option, play around until you satisfy, and trust the compiler.

Redux project uses following essential compiler options:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true
    // other options
  }
}
  • target: which determines which JS features are downleveled (converted to run in older JavaScript runtimes) and which are left intact. That could be: the oldest web browser you support, the lowest version of Node.js you expect to run on or could come from unique constraints from your runtime - like Electron for example.

  • module: which determines what code is used for modules to interact with each other. All communication between modules happens via a module loader, the compiler flag module determines which one is used. At runtime the module loader is responsible for locating and executing all dependencies of a module before executing it.

  • moduleResolution: Specify the module resolution strategy, default to classic if module === AMD or UMD or System or ES6, otherwise node.

  • esModuleInterop: There is a mis-match in features between CommonJS and ES Module because ES Modules only support having the default export as an object, and never as a function. TypeScript has a compiler flag to reduce the friction between the two different sets of constraints with esModuleInterop.

  • skipLibCheck: Skip type checking of declaration files to save time during compilation at the expense of type-system accuracy.

Module Resolution

Starting with ECMAScript 2015, modules are native part of the language, and should be supported by all compliant engine implementations. Thus, for new projects modules would be the recommended code organization mechanism.

Namespaces are a TypeScript-specific way to organize code. Namespaces are simply named JavaScript objects in the global namespace. Unlike modules, they can span multiple files, and can be concatenated using --outFile.

Module resolution is the process the compiler uses to figure out what an import refers to. The compiler will try to find a .ts, .tsx, and then a .d.ts with the appropriate path. If a specific file could not be found, then the compiler will look for an ambient module declaration. Recall that these need to be declared in a .d.ts file.

Node module resolution is the most-commonly used in the TypeScript community and is recommended for most projects.

The most frustrating error in TypeScript is cannot find module. If you are having resolution problems with imports and exports in TypeScript, try setting moduleResolution: node to see if it fixes the issue. A common mistake is to try to use the /// <reference ... /> syntax to refer to a module file, rather than using an import statement.

Declaration Merging

Declaration merging means that the compiler merges two separate declarations declared with the same name into a single definition. This merged definition has the features of both of the original declarations. Any number of declarations can be merged; it’s not limited to just two declarations.

Declaration merging happens between interfaces, namespaces, namespaces with classes, namespaces with functions, namespaces with enums.

interface Cloner {
  clone(animal: Animal): Animal
}
interface Cloner {
  clone(animal: Sheep): Sheep
}
interface Cloner {
  clone(animal: Dog): Dog
  clone(animal: Cat): Cat
}

The three above interfaces will merge to create a single declaration as so:

interface Cloner {
  clone(animal: Dog): Dog
  clone(animal: Cat): Cat
  clone(animal: Sheep): Sheep
  clone(animal: Animal): Animal
}

Not all merges are allowed in TypeScript. Currently, classes can not merge with other classes or with variables.

Generics

Generics are a facility of generic programming that were added to extend a type system to allow a type or method to operate on objects of various types while providing compile-time type safety. Generics are heavily used in complex projects and seem intimidating to beginners.

// from types/middleware.ts
export interface MiddlewareAPI<D extends Dispatch = Dispatch, S = any> {
  dispatch: D
  getState(): S
}

// from compose.ts
export default function compose<A, B, T extends any[], R>(
  f1: (b: B) => R,
  f2: (a: A) => B,
  f3: Func<T, A>
): Func<T, R>

In TypeScript, you can define generic interfaces, generic functions, generic classes. Note that it is not possible to create generic enums and namespaces.

Function Overloads

Some JavaScript functions can be called in a variety of argument counts and types. In TypeScript, we can specify a function that can be called in different ways by writing overload signatures. To do this, write some number of function signatures (usually two or more), followed by the body of the function.

Let’s look at compose.ts file:

// one parameter
export default function compose<F extends Function>(f: F): F

// two parameters
export default function compose<A, T extends any[], R>(
  f1: (a: A) => R,
  f2: Func<T, A>
): Func<T, R>

// three parameters
export default function compose<A, B, T extends any[], R>(
  f1: (b: B) => R,
  f2: (a: A) => B,
  f3: Func<T, A>
): Func<T, R>

/* other overloads */

// implementation
export default function compose(...funcs: Function[]) {
  /* implementation details */
}

Implementation function has compatible signature but not visible and can’t be called directly from the outside. TypeScript chooses the first matching overload when resolving function calls. When an earlier overload is more general than a later one, the later one is effectively hidden and cannot be called.

This is a common source of confusion. Always prefer parameters with union types instead of overloads when possible.