Just today, I finally made a (what first seemed to be) big move towards stability and quality for my employer‘s backend part of the software. Over the last five years, my colleagues and I encountered uncountable issues that would‘ve been easily fixed with clear typing and the added discoverability TypeScript offers. We always postponed this decision, as we thought it wasn‘t feasible to do incrementally — but of course large rewrites of software rarely happen, especially for companies as young as this one.

As my studies needed me to cover some parts on engineering practice, it became more and more clear that we had to change something rather soon than later. I recently onboarded a new engineering colleague and already noticed that the backend was that obscure part, where things just „magically“ happen due to almost no discoverability.

Adding TypeScript 5

So, enough of the banter, here‘s how I added TypeScript 5 support in less than two hours for a huge repository.

The magic, of course, lies in incremental change. Whenever I will touch old modules, I‘ll carefully evaluate breaking them up or converting them to use TypeScript, too. Interfacing with the „old world“ still works as long as it‘s unidirectional: new code (TypeScript) using old code (JavaScript). This also comes with the (tedious, however) benefit that it provides a clear migration path. However, some new modules still work depending on how you export them. I’ve noticed that exporting functions like export const test = () =>... can also be used from old .js files.

Let’s begin by installing TypeScript, we also use Express.js, so I installed types for it, too:

npm i --save-dev typescript @types/express

I also created a new tsconfig.json file with the following options. Those work well for my setup and ensure that most of the legacy code is compatible, so that adoption does not interfere with incremental change.

{
  "compilerOptions": {
    "target": "esnext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
    "module": "commonjs" /* Specify the root folder within your source files. */ /* Specify what module code is generated. */,
    "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
    "resolveJsonModule": true /* Enable importing .json files. */,
    "allowJs": true /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */,
    "outDir": "./build" /* Specify an output folder for all emitted files. */,
    "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
    "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
    "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
    "strict": true /* Enable all strict type-checking options. */,
    "skipLibCheck": true /* Skip type checking all .d.ts files. */
  }
}

Of course, the most important option is allowJs so TypeScript neatly copies over everything. I also enabled resolveJsonModule because we are doing a bunch of require('sample.json') and this makes them work just fine.

It also makes sure that the old code still works when converted in terms of module imports (or require statements).

Teaching Express.js about your model

We are attaching a user object to our requests, so a lot of existing code would rightfully complain about referecing something that doesn’t exist. To mitigate this, I added a express.d.ts file inside the root directory (or anywhere else is fine, too) that teaches TypeScript to know about the object.

import { RequestUser } from './app/middleware/requestUser';

declare global {
  declare namespace Express {
    export interface Request {
      user: RequestUser;
    }
  }
}

That’s already it! Now you can incrementally turn your .js files into .ts files. They will break of course, but that’s wanted, because it forces you to specify types (don’t just slap any everywhere!), thus increasing your team’s discovery. Enjoy the peace of mind!

Incrementally adopting new types with typealiases

When converting your .js files to .ts files it can be a lot of effort to create types for your existing data and functions. One trick I’ve started using is making a typealias and exporting it. Once you’ve covered a more parts of your software, you can then change the existing alias to an interface and discover all the ways that type is used with.

Go from no types

const test = payload => {};

…to more context with well-named aliases

export type TestFunctionPayload = any;

const test = (payload: TestFunctionPayload) => {};

…to full context

export interface TestFunctionPayload {
  name: string;
  userId: number;
}

const test = (payload: TestFunctionPayload) => {};