TypeScript Best Practices at Viget
Lessons learned building with TypeScript.
At Viget, we’ve invested heavily in TypeScript. It’s been a great boon to our team’s ability to ship reliable, maintainable software. In time we’ve developed some opinions about how best to employ this technology for our clients.
This post assumes a familiarity with TypeScript. For an introduction to the language, see TypeScript: Documentation - Introduction.
Table of Contents #
- Configuration
- Type inference
- Features to avoid
any
vsunknown
- Fixed-length arrays
const
assertions- Type assertions
type
aliases vsinterface
s- Access modifiers
- Linting & Formatting
tsc
vsbabel
Configuration #
Use
strict: true
intsconfig.json
, and enable other strictness checks
Turning on strict: true
will enable all of the following features which help TypeScript give us more assurance about the correctness of our code:
strictNullChecks
strictBindCallApply
strictFunctionTypes
strictPropertyInitialization
noImplicitAny
noImplicitThis
useUnknownInCatchVariables
We also favor opting out of implicit returns and unused local bindings.
Though we don’t often bump into it, inconsistent casing in filenames /can/ be a source of cross-environment issues.
Babel automatically synthesizes default exports, so enabling the following allows TypeScript to behave similarly.
// without synthetic default imports:
import * as React from 'react'
// with synthetic default imports:
import React from 'react'
Type Inference #
Use type inference as much as possible
The TypeScript compiler is smart, we prefer to let it infer types for us. Inferred types are less explicit, but more flexible. This means less manual manipulation of types as the system evolves and types change. It’s less explicit though so things like IDE support are pretty crucial. Occasionally TypeScript can’t infer the correct type and we need to provide explicit types.
// WRONG
const items = []; // incorrectly inferred as never[]
items.push(1); // error: Type 'number' is not assignable to type 'never'.
// RIGHT
const items: number[] = [];
items.push(1);
Use typed type-programming and utility types for reflection, digging into, and composing types
It’s typical to work with types that are related to other types, whether those are part of our applications or from a third-party library. Because TypeScript uses structural sub-typing, we have a measure of flexibility in how we express the constraints of types in our code. Using composition and utility types to derive or create types that are interrelated gives us greater assurances and reduces maintenance overhead.
ts-essentials
is a great library for expanding your arsenal of utility types.
// inferred as () => Promise<number>
function fetchWidgets() {
return new Promise(resolve => resolve(1))
}
type FetchReturn = ReturnType<typeof fetchWidgets>; // Promise<number>
type FetchReturnValue = Awaited<FetchReturn>; // number
Features to Avoid #
Avoid
enum
s, use literal unions
TypeScript supports the enum
keyword for creating enumerated types, but it breaks the contract that TypeScript is “JS with static type features added” due to generating additional runtime code. This is evident if you look at the tsc
compiler output for this simple example.
// BAD
enum Scripts {
JavaScript,
TypeScript,
PureScript,
CoffeeScript
}
// GOOD
type Scripts = "JavaScript" | "TypeScript" | "PureScript" | "CoffeeScript"
Comparing the two approaches in practice, it’s true that updating the value of an enum member is simpler: just change the enum
definition and you’re done (the rest of your code references the static constant enum value). In the case of literal unions you will need to change all the places that reference the value. In practice, however, the values of enum members rarely change after being added and the compiler will identify all the parts of the code that need to be updated.
Avoid
namespace
s, just use ES modules
The namespace
keyword in TypeScript can be used to group related code, like modules, but more than 1 namespace can be declared inside a module. Stripping the TypeScript syntax/annotations yields invalid JS which means tsc
has to emit some generated code to make them work which, similar to enum
s, breaks the contract.
The smart folks over at executeprogram.com wrote about this earlier this year.
any
vs unknown
#
Avoid
any
, useunknown
when a type is ambiguous and use type-narrowing before dereferencing it
A few things to keep in mind:
any
makestsc
bail on type checking entirely, which tends to have a cascading effect on TypeScript's understanding of the codeunknown
is the type-safe equivalentlet thing: unknown = 1; // ok
Anything can be assigned to
unknown
, butunknown
isn’t assignable to anything but itself andany
without a type assertion or a control flow based narrowing:let n: number = thing; // type error, because we declared `thing` as `unknown`
No operations are permitted on an
unknown
without first asserting or narrowing to a more specific type. Type Guards are a great way to narrow broad types likeunknown
safely:function isNumber(n: unknown): n is number { return typeof n === 'number'; }
Fixed-length arrays #
Use fixed-length arrays, where appropriate
Declaring an array type can be done as a fixed or variable length declaration. Fixed-length arrays are useful for modeling “tuple” types - an ordered list type with a finite number of elements.
Variable-length
let items: number[] = [1, 2, 3]
items = [] // ok
items = [1] // ok
items = [1, 2] // ok
items = [1, 2, 3, 4, 5] // ok
Fixed-length
let items: [number, string] = [1, "ts"]
items = [4, "js"] // ok
items = [1, "ps", 3] // error
// Type '[number, string, number]' is not assignable to type '[number, string]'.
// Source has 3 element(s) but target allows only 2.
This is useful when if you have an array that has a specific “schema” with a fixed length.
const
assertions #
Use const assertions, where appropriate
TypeScript type inference is quite smart, but it tends to infer the widest or most general type. Using const
assertions causes TypeScript to infer the narrowest possible type. This is often useful for constant values, especially dictionaries or lists of known elements.
let items = [1, 2, 3]; // number[]
let items = [1, 2, 3] as const; // readonly [1, 2, 3]
A slightly more complex, practical example:
let computers = [
{ kind: "laptop", screenSize: 15 },
{ kind: "desktop", height: 24 },
] as const;
for (const computer of computers) {
if (computer.kind === "laptop") {
console.log("Laptop screen size", laptop.screenSize);
/**
* Without `as const` `computer` here is:
* {
* kind: string;
* screenSize: number;
* height?: undefined;
* } | {
* kind: string;
* height: number;
* screenSize?: undefined;
* }
*
* but with `as const`, `computer` is correctly narrowed to:
*
* {
* readonly kind: "laptop";
* readonly screenSize: 15;
* }
*/
} else {
console.log("Desktop height", computer.height);
}
}
Type Assertions #
Avoid type assertions, unless necessary
Type assertions let us tell TypeScript what the type of a certain thing is which is a lot like type casting (but not as a runtime effect). Using them is sort of an escape hatch that the compiler cannot statically verify. There are some rules around their usage that exist to prevent us from breaking known static contracts, but they can be abused when used incorrectly. Type assertions are important since there will be times where you as a developer know more about the type of a thing than TS can infer.
// HTMLElement
const myCanvas = document.getElementById("canvas") as HTMLCanvasElement;
Type assertions only allow narrowing and widening of types. tsc
won’t let you declare a type assertion that’s incompatible with what it knows about a type.
const x = "hello" as number;
// error: Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
There are cases where you might need to escape from this limitation. To do so you may do the following:
const thing = other as unknown as Thing;
This occasionally comes up when working with third-party libraries and tricky or impossible type inference. Look for opportunities to pass generic arguments first before reaching for this kind of pattern.
type
aliases vs interface
s #
Use
type
aliases for React props/state, be consistent, utilizeinterface
s when authoring library public APIs or extending third-party types
- TypeScript has 2 ways of declaring “object-like” types,
type
andinterface
- in most cases, they accomplish the same task
- there are some edge cases and limitations to be aware of that may lend certain use-cases to one or the other approach
We favor type
aliases for most of our needs. They are explicit, concise, and aren’t susceptible to monkey-patching like interface
s are (due to declaration merging).
Note: these examples are adapted from Martin Hochell’s excellent post on Medium.
1. you cannot use implements
on an class with type alias if you use union operator within your type definition
class Vehicle {
tankCapacity: number;
fuelEfficiency: number;
}
interface Traveller {
range(): number;
}
type Fillable = {
topupCost(perPerGallon: number): number
}
type SubaruVehicle = (Traveller | Fillable) & Vehicle;
// error: a class may only implement another class or interface
class Subaru implements SubaruVehicle {
tankCapacity: 15;
fuelEfficienty: 35;
range() {
return this.tankCapacity * this.fuelEfficiency;
}
}
2. you cannot use extends
on an interface with type alias if you use union operator within your type definition
type FillableOrTraveller = Fillable | Traveller;
// error: an interface may only extend a class or other interface
interface SubaruVehicle extends FillableOrTraveller, Vehicle {}
3. declaration merging doesn’t work with type alias
TypeScript will merge interface
declarations with the same name (across all source files that tsc
is aware of). This is incredibly useful for extending types provided by third-party libraries but should be avoided within your own app’s source code.
interface Box {
height: number;
width: number;
}
interface Box {
scale: number;
}
const box: Box = { height: 2, width: 4, scale: 1 }; // ok
If you try this with a type alias, you’ll see “Duplicate declaration Box
”
Type aliases and interfaces when working with React props/state
// BAD
interface Props extends OwnProps, InjectedProps, StoreProps {}
type OwnProps = {...}
type StoreProps = {...}
// GOOD
type Props = OwnProps & InjectedProps & StoreProps
type OwnProps = {...}
type StoreProps = {...}
Access modifiers #
Use access modifiers for class properties
JavaScript hasn’t had real private
and protected
fields (until ES2022 which added the #
prefix for private fields), but TypeScript can guard against violations of these constraints.
When working with classes it’s recommended to utilize these features to enforce access-related constraints within your software.
class Employee {
protected name: string;
private salary: number;
constructor(name: string, salary: number) {
this.name = name;
this.salary = salary;
}
public getSalary() {
return salary
}
}
Linting & Formatting #
Use ESLint / Prettier with TypeScript
Using linters helps us maintain high code-quality standards, (we’re fans of making computers do work instead of humans, whenever possible). Auto-formatting code ensures consistency, saves time, and avoids bike-shedding.
Setting up ESLint and Prettier for TypeScript is mostly straightforward, with one caveat related to Prettier. Here’s an example repo with a barebones setup that implements this workflow.
1. Install the relevant packages
$ npm i -D eslint \
prettier eslint-config-prettier eslint-plugin-prettier \
typescript @typescript-eslint/eslint-plugin
2. Configure ESLint
// .eslintrc.js
module.exports = {
parser: '@typescript-eslint/parser',
extends: [
'plugin:prettier/recommended',
'plugin:@typescript-eslint/recommended',
],
};
3a. Run ESLint (manually), using the --fix
flag to automatically apply fixes to source files
$ eslint --fix ./src/*{.ts,.tsx}
3b. Run ESLint (automatically) on save in VS Code
settings.json
(VS Code)
{
"eslint.format.enable": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
}
}
4. Set up ESLint in a Continuous Integration environment
We often use GitHub Actions for CI due to its convenience when using GitHub for collaborating on git-based projects.
package.json
{
...,
"scripts": {
"lint": "eslint --fix ./src/*{.ts,.tsx}"
}
...
}
.github/workflows/ci.yml
name: GitHub CI
on: [push]
jobs:
lint-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
cache: true
- run: npm ci
- run: npm run lint
tsc
vs babel
#
If using
babel
, we make sure to actually type-check code
Both the TypeScript compiler (tsc
) and babel
can be used to transform TypeScript source code into JavaScript (with tsc
if config.noEmit = false
, which is the default, or with babel
via the @babel/preset-typescript
preset).
However, there are a few things to keep in mind:
- The babel transformation does not perform type-checking, so we must either run
tsc
withnoEmit = true
as part of the build and on CI or rely on IDE integration to check our types - If working on a project that relies on babel compilation, it’s convenient to just add another preset and avoid the complication of 2 separate compilers emitting build artifacts
- For compiling TypeScript files with
webpack
, you can use either approach (either usets-loader
which usestsc
internally, or just add the preset to your babel configuration if usingbabel-loader
and have it process.js
and.ts
files)
TypeScript React Cheatsheet #
There are a handful of common things you might want to do when building React applications using TypeScript. This reference is one you'll want to bookmark and come back to frequently.
In Closing #
TypeScript is a valuable tool that we continue to use regularly, invest in, and refine our opinions and best practices with. We hope our experience and the lessons we have learned can help you avoid common pitfalls and understand the trade-offs of different approaches to using TypeScript on your projects.