Skip to main content
Dublin Library

The Publishing Project

Configuring Typescript for new projects

 

I've finally given in to working with TypeScript despite some of my misgivings about the technology.

My misgivings include:

  • The need to set up a transpiler toolchain
  • You can't run the code directly in the browser
  • You have to decide on what version of Javascript to transpile the code to. This decision will be hardcoded in the Typescript configuration file

We'll look at the setup and configuration of a Typescript project, along with some gotchas I've learned along the way.

These instructions assume you've initialized a package.json file.

Installing Typescript #

You have two options to configure Typescript. You can install it globally to work on any project and any directory.

To install Typescript globally run the following command.

npm install -g typescript

You can also install Typescript on individual projects. This is preferred as you can work with different versions of Typescript for each project.

The command for project installation uses the -D flag to install it as a developer dependency.

npm install -D typescript

Adding scripts to package.json #

The next step is to add the commands to run Typescript-related commands.

The first command, tsc, will just run TSC to compile all TSC files in the project. It will exit when the compilation is complete.

The second command, watch sets the Typescript compiler's watch mode. It will compile all files and then wait for your to make changes at which point will automatically run the compilation process again.

{
  "scripts": {
    "tsc": "tsc",
    "watch": "tsc -w"
  }
}

To run the commands use npm run tsc and npm run watch.

Configuring Typescript #

So far, we've installed Typescript and set up the commands to run Typescript in the project's package.json file.

We now have to configure Typescript. Rather than manually type the configuration, I'll leverage Typescript to generate one for me.

npm run tsc --init

This will generate a tsconfig.json file with all the possible settings for your project.

We will not cover all the possible settings. You can do that by inspecting the generated files and the comment after each setting or by checking the TSConfig Reference documentation.

The target attribute indicates the version of Javascript that we want to transpile our code to.

The lib attribute is an array of the libraries that we want to use in the Typescript project.

I also include the DOM and WebWorker libraries since most of the work I do is web-related and I want to use web workers in future projects so I better add them now.

"target": "ES2017",
"lib": [
  "ES2017",
  "DOM",
  "WebWorker"
]

I chose ES2017 my target because it provides a sane set of features that work in at least 90% of browsers today.

According to Houssein Djirdeh and Jason Miller's Publish, ship, and install modern JavaScript for faster applications

This means that 95% of global web traffic comes from browsers that support the most widely used JavaScript language features from the past 10 years, including:

  • Classes (ES2015)
  • Arrow functions (ES2015)
  • Generators (ES2015)
  • Block scoping (ES2015)
  • Destructuring (ES2015)
  • Rest and spread parameters (ES2015)
  • Object shorthand (ES2015)
  • Async/await (ES2017)

Features in newer versions of the language specification generally have less consistent support across modern browsers. For example, many ES2020 and ES2021 features are only supported in 70% of the browser market—still the majority of browsers, but not enough that it's safe to rely on those features directly. This means that although "modern" JavaScript is a moving target, ES2017 has the widest range of browser compatibility while including most of the commonly used modern syntax features. In other words, ES2017 is the closest to modern syntax today.

ES2017 is the earliest version of the Javascript standard that supports modules. This enables the module/nomodule pattern as an imperfect solution to supporting older browsers.

Targetting 2017 and ES5 would allow to create two bundles, one for modern code and one for older, legacy code, and use them like this:

<script type="module" src="bundle.modern.js"></script>
<script nomodule src="bundle.legacy.js"></script>

Browsers that support modules will load the modern bundles and bundles that don't will load the legacy bundle with all the necessary polyfills.

One of the biggest problems with the module/nomodule pattern is third-party libraries. Gary Chew documented the problem in Bringing Modern JavaScript to Libraries

I also want to allow Javascript files in this project. Allowing .js files in a Typescript project doesn't mean that the Javascript files will be type checked.

That is a separate setting that I've chosen not to enable since most of the Javascript files will come from third parties.

"allowJs": true

The largest chunk of options is for type checking.

"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowUnusedLabels": true,
"allowUnreachableCode": true,

Several checks in this list will help check assumptions I make about the code.

For example, noImplicitAny will flag if Typescript can't infer/guess the type of a variable and fall back to the implicit default type any.

This may be ok in some cases but can cause unexpected errors if we pass a variable that was not expected.

Some of these settings will prevent footguns. noFallthroughCasesInSwitch will flag any non-empty case inside a switch statement that doesn't include either break or return statements.

Rather than relying on case fall-through behavior if you want to group cases together, be explicit about the grouping with something like this:

const pet:string = "dog";

switch (pet) {
  case "lizard":
  case "snake":
    console.log("I own a reptile");
    break;
  case "dog":
  case "cat":
    console.log("I own a house pet");
    break;
  case "parrot":
    console.log("I own a parrot");
    break;
  default:
    console.log("I don't own a pet");
    break;
}

And I'm still on the fence about some of these tests, specifically allowUnusedLabels and allowUnreachableCode. While they may make sense during development they are not useful or meaningful in a production script or app.

For example, in the code below, the final return true statement will never be reached. Was that meant to be another else block so the first one would be an else if or another if block?

function fn(n: number) {
  if (n > 5) {
    return true;
  } else {
    return false;
  }
  // This will never be reached
  // Is the code structured correctly?
  return true;
}

Conclusion: why go through all the trouble? #

Setting up Typescript by hand is not trivial.

Yes, we could setup Vite with the vanilla-ts template, and it would make perfect sense for some projects. But we still have to change the configuration to what makes the most sense to us and the projects we are working on.

Why did I choose to switch? Typescript keeps me honest. It makes me think about the code as I'm writing it rather than figuring out problems after the code is written and published.

Yes, I know Typescript is not the solution to all problems, but I'll take all the help I can get.

Edit on Github