The TypeScript Developers Toolbox

Part One: Project Tooling and Configuration

by: Simon Plenderleith

When starting a new TypeScript project, it's important to establish a solid foundation for development. Project tooling and configuration becomes more difficult to change as a project matures, so the decisions you make on day one can have a big impact.

Navigating the vast landscape of available tools and integrating them into your TypeScript projects can be a daunting task. We've crafted this comprehensive guide to assist you and your team in making well-informed decisions. Let's dive in and take a look at the tools that can help you set up your TypeScript projects for success!

TypeScript Configuration

TypeScript offers a dizzying array of configuration options. It can be overwhelming trying to figure out which options and values you need for your project, but fortunately there are a number of tried and tested TSConfig base configurations that you can adopt and extend.

tsconfig/bases

Created by Orta Therox in 2020, tsconfig/bases was the first community project of it its kind to provide a set of base TSConfig configurations. It offers a wide selection of configurations that have been tuned for specific runtimes and tooling, such as Node.js, Deno, Bun, Next.js, Vite and Cypress.

Each TSConfig configuration is published to npm as a separate package. For example, If you wanted to use the configuration for Node.js v20, you would first install it as development dependency for your project:

npm install --save-dev @tsconfig/node20

And then create a tsconfig.json which extends it:

// tsconfig.json

{
"extends": "@tsconfig/node20/tsconfig.json"
}

This will provide you with a solid base configuration, while also allowing you to extend it with your own custom settings if needed. For example:

// tsconfig.json

{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"outDir": "dist"
}
}

@total-typescript/tsconfig

@total-typescript/tsconfig is a well tested and documented set of base TSConfig configurations, created by Matt Pocock. He's also published a cheat sheet explaining the reasons behind his recommended settings.

Rather than providing framework-specific configurations, the @total-typescript/tsconfig package instead provides configurations that can be used in more general contexts, such as for an app or for a library. It also allows you to distinguish whether you're running your code in the DOM, and whether you're compiling it with the TypeScript compiler (tsc) or a bundler. Based on those criteria, it guides you towards selecting the best configuration for your use case.

Similarly to tsconfig/bases, after you've installed the @total-typescript/tsconfig package as a development dependency in your project, you can create a tsconfig.json which extends one of the base configurations. For example:

// tsconfig.json

{
"extends": "@total-typescript/tsconfig/tsc/dom/app"
}

Code Formatting & Linting

Tabs vs spaces? Semicolons or no semicolons? If there's one thing developers love debating, it's preferences for code style! Adding a code formatter and a linter into your project can help everyone in your team commit in the same agreed style, as well as catch potential TypeScript anti-patterns.

Before we take a look at the different tools that are available, let's take a moment to understand the differences between formatting and linting:

  • Formatting. Tabs or spaces, semicolons or no semicolons, parenthesis positions — a good formatter will take care of all of these formatting concerns and more, and will allow you to configure it to match your code style preferences. Formatting tools help ensure that all the code in your project is formatted consistently, making it easier to read and navigate.
  • Linting. Linting tools analyze the structure and syntax of your code without running it. This process is known as static analysis. It allows linters to highlight TypeScript anti-patterns and common mistakes in your code. Linters come with a set of predefined rules that can be toggled on or off, depending on your preferences, and typically allow you to define custom linting rules too. They can also typically be configured to automatically fix simple issues in your code. Using a linter can help improve code quality, security, and maintainability.

Formatters: EditorConfig

EditorConfig is a file format for defining coding styles, aimed at helping teams working on the same codebase to maintain consistency, regardless of which code editor or IDE they're using. Visual Studio Code and a number of other editors support EditorConfig out-of-the-box, and automatically look for an .editorconfig file in a project. Here's an example EditorConfig file:

# .editorconfig

# top-most EditorConfig file (projects can contain multiple configs if needed)
root = true

# Apply these rules to all files
[*]
# Soft or hard tabs
indent_style = tab
# Number of columns for each indentation level
indent_size = 2
# Which character to use for line breaks
end_of_line = lf
# Character set to use
charset = utf-8
# Remove whitespace characters at the end of lines
trim_trailing_whitespace = true
# Ensure files end with a newline character
insert_final_newline = true

# Apply these rules only to files ending in '.md' (Markdown)
[*.md]
trim_trailing_whitespace = false

Once you've added an EditorConfig file into your project, your code editor will automatically apply these formatting rules when creating new files or editing existing ones. A great first step towards consistent code formatting!

Formatters: Prettier

While Visual Studio Code provides a built-in formatter specifically for TypeScript code, the popular formatting tool Prettier is a highly configurable and popular alternative. It supports formatting for TypeScript code, as well as a number of other languages such as JavaScript, HTML, CSS and Markdown. Prettier will even read your project's .editorconfig file if it exists, and apply those settings where appropriate. Depending on your needs, it might not even be necessary to add specific configuration for Prettier.

If you're using Visual Studio Code, there's a Prettier extension you can install to handle formatting code in your editor during development.

Linters

When it comes to linting your code, typescript-eslint is the de facto linting tool for TypeScript projects. To be a little more precise, it's a set of tooling that is layered on top of the popular linting tool ESLint. typescript-eslint provides a large set of TypeScript specific lint rules, and most importantly allows ESLint to be run against TypeScript code, as well as providing type information that allows linting rules to be type-aware.

The typescript-eslint project provides a playground which you can use to test out different linting rules and help you build up your ideal linting configuration. Here's an example configuration file for ESLint which integrates typescript-eslint, enabling two recommended sets of linting rules, and also enabling an array-type rule:

// eslint.config.mjs

// @ts-check

import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
// Add ESLint's set of recommended rules.
eslint.configs.recommended,
// Add typescript-eslint's set of recommended rules.
...tseslint.configs.recommended,
// Configure specific lint rules.
{
rules: {
// "Require consistently using either T[] or Array<T> for arrays."
"@typescript-eslint/array-type": "error",
},
}
);

Just like Prettier, ESLint has a Visual Studio Code extension, which will automatically lint your code and highlight linting errors inline in your editor.

Tip: If you've ever used ESLint before and found it tricky to configure, you might be happy to hear that the old configuration format has been deprecated in favor of a much simpler "flat" configuration file format.

Built-in and All-in-One Tools

Having fewer tools to configure and integrate can help simplify project setup. If you're using Deno, the good news is that it provides a built-in formatter, as well as a built-in linter.

Deno's formatter is powered by Prettier. Just like Prettier, Deno's formatter applies sensible defaults, but it can also be configured via your project's deno.json file. You can run the formatter against all the code in your project with deno fmt. The linter in Deno is configurable too, and can be run with deno lint.

If you aren't using Deno, you might want to consider one of these all-in-one formatting and linting tools which support TypeScript:

  • Biome (formerly known as Rome). Provides an opinionated formatter which is largely compatible with Prettier. Biome's linter provides a number of lint rules from the ESLint ecosystem, as well as adding some rules of its own. The biome CLI provides commands to automatically migrate your ESLint and Prettier configurations to Biome's configuration format. There are also Biome extensions for many popular code editors, including Visual Studio Code.
  • xo. Built on top of ESLint, xo is an opinionated but configurable linter and formatter, created by prolific open-sourcerer, Sindre Sorhus. xo even supports the Prettier code style if you prefer it. One caveat to be aware of is that xo requires your project to be using ES modules.

Running Formatters and Linters

Once you set up formatting and linting checks for your project, you'll want to run them in your continuous integration (CI) environment to help keep all committed code consistent. Tools like Husky and lint-staged make it possible for you to configure your formatting and linting checks so that they're run in a git pre-commit or pre-push hook.

Here's an example package.json, configured with Prettier, typescript-eslint, ESLint, Husky and lint-staged:

// package.json

{
"name": "web-project",
"scripts": {
"format": "prettier --write .",
"lint": "eslint src/ --cache --fix",
"prepare": "husky"
},
"lint-staged": {
"*.ts": "eslint --cache --fix",
"*.{js,ts,md,html,css}": "prettier --write"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@types/eslint__js": "^8.42.3",
"eslint": "^9.11.1",
"husky": "^9.1.6",
"lint-staged": "^15.2.10",
"prettier": "3.3.3",
"typescript": "^5.6.2",
"typescript-eslint": "^8.7.0"
}
}

Knowing that your code passes formatting and linting checks early, before it hits CI, can speed up the feedback loop during development.

Testing

Tests are just as important a part of a project as the application code itself. Historically, configuring test frameworks to play nicely with TypeScript could be a tricky affair, but in recent years support has improved considerably. Let's take a brief tour of the TypeScript testing landscape!

TypeScript Support with Additional Tools

The classic JavaScript testing frameworks, Jest and Mocha, don't natively support TypeScript, but they can be configured to work with it using additional tooling and configuration.

To get Jest to work with tests written in TypeScript, you can combine it with the Babel compiler or the Jest transformer, ts-jest. If you want to achieve the same thing with Mocha, it can also be combined with Babel, or alternatively with the popular TypeScript code runner ts-node.

Native TypeScript Support

Extra tooling and configuration typically means additional complexity. Fortunately there are a couple of test frameworks which provide out-of-the-box support for TypeScript:

  • Vitest. Released in 2021, Vitest is currently the best option for writing front end and back end tests in TypeScript. As well as offering native support for TypeScript and optional type checking, it also provides functionality for testing types. Vite is Jest compatible, so migrating an existing test suite to Vitest should be relatively straightforward.
  • node-tap. If you’re only writing tests for Node.js applications, node-tap is another great option with built-in TypeScript support. Once you've enabled the tap TypeScript plugin in your .taprc file, node-tap allows you to optionally enable type checking for your project too.

The Deno and Bun runtimes both provide built-in test runners, making testing even easier to set up in a new project.

Testing types

If you find yourself writing complex types — when writing shared code or library code, for example — it's a good idea to test them. You might not be implementing an SQL database or a spell checker with your types, but you want to be confident that they're behaving the way you expect them to.

expect-type

expect-type is a lightweight, zero dependency library that you can use for testing your types. It's even used by Vitest under the hood to provide type testing functionality.

Once you've installed expect-type as a development dependency for your project, you can then import the expectTypeOf function and access a number of handy type-matchers, for example:

import { expectTypeOf } from 'expect-type'
import { user, getUserById } from './user.js'

// Ensure that `user` has a type of `{ id: number; username: string; }`.
expectTypeOf(user).toMatchTypeOf<{ id: number; username: string; }>()

// Ensure that `bar` is a function which accepts a number.
expectTypeOf(getUserById).parameter(0).toBeNumber()
expectTypeOf(getUserById).returns.not.toBeAny()

The expectTypeOf() function doesn't do anything at runtime, but you'll see any assertion failures when you compile your TypeScript project.

tsd

If you need something more fully featured for testing your types, you might want to take a look at tsd. tsd requires you to write your type tests in files with a .test-d.ts extension, and provides a number of methods, such as expectType and expectAssignable for asserting types.

The tsd CLI automatically reads your project's tsconfig.json so that it can apply the same TypeScript compiler options that you're using for your codebase. However, your test files won't be compiled or executed, but instead will be statically analyzed and compared against your type definitions.

Utility Libraries

TypeScript includes a number of globally available utility types. These allow you to derive new types from other types. For example, the Readonly utility type accepts an object type and returns a new type where the readonly modifier has been applied to all of its properties:

type ReadonlyCity = Readonly<City>;

const city: ReadonlyCity = {
name: "Berlin",
country: "Germany",
};

// Error: Cannot assign to 'name' because it is a read-only property. TS2540.
city.name = "Hamburg";

As your applications grow, you might find yourself needing a more comprehensive set of utility types. These popular libraries are great options for improving the type safety of your code:

  • ts-toolbelt. With over 12 million monthly downloads, ts-toolbelt is currently the most popular type library. It provides over 200 utility types, along with a comprehensive set of documentation. Types are helpfully grouped together under namespaces, such as Object and Function, making it easier to find just the type you need.
  • type-fest. This type library provides an impressive 140+ utility types. The documentation for each type is helpfully included in the source code alongside the type itself.
    • Tip: The Simplify type is one of our favorites, allowing you to flatten a complex type into one that your code editor can provide better type hints for.
  • ts-reset. TypeScript has built-in types for common JavaScript APIs, such as JSON.parse and Array.prototype.filter(). Unfortunately not all of these built-in types are ideal. For example, a number of the function return types are defined as any, which effectively disables type safety for any code which uses the values returned by those functions. Adding ts-reset into your project will override some of TypeScript's built-in types with improved versions, resolving these potential footguns, and bringing better type safety to your code.

Tip: Digging into the source code of the types in these libraries can be a good way to improve your understanding of more complex types. You might even pick up a few new type tricks along the way!

Project Templates

While choosing and integrating individual tools can provide you with a customizable and flexible foundation for your project, you want also might want to consider a "done for you" approach, such as the one provided by create-typescript-app.

Created by Josh Goldberg — author of 'Learning TypeScript' and maintainer of typescript-eslint — create-typescript-app is a command-line tool which bills itself as a "quickstart-friendly TypeScript template with comprehensive, configurable, opinionated tooling." It's a great way to get a new project started with pre-configured tooling like Prettier, ESLint, typescript-lint, Husky, lint-staged and Vitest, as well as a whole host of other neat tools and configuration.

The create-typescript-app CLI provides an interactive mode, allowing you select different options step-by-step, as well as a non-interactive mode, which is handy if you want to script the setup for your new projects. It also lets you decide how much tooling you'd like it to set up for you, allowing you to choose between either a minimal set of tools for building, formatting, linting, and type checking, a common set of tools — minimal + testing + tooling for contributors and releases — or everything, which sets up your project with all the supported tooling, including a spellchecker and extra linting rules. If you prefer, you can even decide on a per-tool basis whether you want to use it in your new project.

If you have an existing project that you'd like to migrate to use the tooling that create-typescript-app configures, the create-typescript-app CLI even provides a migration mode to get you up and running.

Next Steps

In the next part of this series, I'll share more about how to run TypeScript code.

Want to start working with TypeScript but struggling to implement it on your team? In our TypeScript for Teams workshop, your whole team can get hands-on experience in a friendly format!