An approach to handle massive amounts of TypeScript legacy

20 jan 2024

1. Table of contents

2. TL;DR

When onboarding on a TypeScript or just migrated to TypeScript project you might encounter way too permissive TypeScript configuration, that could result in runtime bugs or just not covered use cases.
When accumulating through time this might become not humanly handle-able.

Configuration example:

{
  // ...
  "compilerOptions": {
    "strict": false,
    "strictNullChecks": false,
    "strictPropertyInitialization": false,
    "skipLibCheck": true,
    "noImplicitAny": false
    // ...
  }
}

It’s not even imaginable to manually fix thousands of ts-errors. To a point that this might never be !
When you can’t rely on your types, you must regain trust on other deterministic pillar of your application such as:

  • Testing, in aim to freeze the application’state
  • Remove deadcode, using for example knip, we don’t wanna loose time fixing ts-errors on anything unused.
  • Outgoing and incoming data, following API contracts.

Afterwards it becomes more imaginable to, step by step, refactor features by features, define good routine within your team to avoid introducing new legacy and in parallel of development fix the old one as possible.

3. Introduction

In this text I’m going to describe the path I would take, as of today, to handle strong legacy on a TypeScript application, either server or front.
Lets say we’ve just landed on a new job or we’re just given the responsibility of an existing and still currently developed TypeScript application.

Giving a look to the tsconfig you see this:

{
  // ...
  "compilerOptions": {
    "strict": false,
    "strictNullChecks": false,
    "strictPropertyInitialization": false,
    "skipLibCheck": true,
    "noImplicitAny": false
    // ...
  }
}

From there if you’re working on an Node.js API, I would be personally very worried of any security potential data leakage and consistency of your contract API.
Anyway, our goal is to enhance the application, avoid recurrent production issues or what ever, by improving, if it’s ont activating TypeScript strictness on it.

4. Why it’s important

I might not be objective as I really love TypeScript, but in my opinion, TypeScript is often seen as enemy to production shipping. Who am I to say it might not be accurate regarding the product and working context. But overall TypeScript, again in my opinion, is MUST on all the following aspects:

  • Dev env comfort
  • Confidence toward application stability
  • Maintainability
  • Standardization

The first thing I wanna do, as a TypeScript developer, is design my types. What is the model I’m going to work on, how will it mutate to be able to be processed afterwards etc. All of those steps that allows you taking a step back on the technical aspect of the implementation such as could be doing TDD Test Driven development.

I also want the ts-server to shout out at me when he detects anything I haven’t been thinking of, often intentionally when I want him to spread an interface mutation to guide me on what now needs to be done.
There’s so many framework and tools in the ecosystem, TypeScript is a layer now widely shared between nearly all of them, allowing to expose a meaningful user interface to anyone that understands TypeScript, agnostically of the stack under the hood.

5. The problematic flags

We will shortly list all the most recurrent problematic flags, too often activated by mistake. Making TypeScript way too permissive.

5.1. “strict”

Mainly activating this flag will disable or enable the below flags depending if they’re giving or skipping strictness

5.2. “strictNullChecks”

In my opinion this the most important one, this intensifies the others “bad” flags

Such operations does not raise any ts errors anymore:

type Foo = {
  bar: string;
};

// Would not raise any errors
const toto: Foo = undefined;
const titi: Foo = {};
const tata: Foo = null;
const tata: Foo = {
  bar: null,
};
const tutu: Foo = {
  bar: undefined,
};
// ... and so many more

5.3. “strictPropertyInitialization”

By activating this flag no need to assign class properties after declaration. By experience found several times on NestJs apps, when declaring model.

Combined with the strictNullChecks this can be really dangerous leading to unhandled behavior.

class Foo {
  bar: string;

  constructor(value: string | null) {
    // here at runtime this.bar can either be undefined null or a string whereas is only typed as string
    this.bar = value;
  }
}

5.4. “skipLibCheck”

Might be really legitimate to be used depending on the project, but might quite often be lazy shortcut to what could be a types peer dependency issue.

6. The diagnostic

First of all we have to determine the proportion of the task. It will depends on several factors such as the application size, global implementation paradigm ( imperative or declarative ) and application testing coverage. Lets toggle all of our problematic flags and run tsc --noEmit.

Note that just like a train a ts-error might hiding an other one. The amount of errors might be misleading but it still gives a pretty good overview of the work to be done.

Found 8987 errors in 716 files.

Errors  Files
     #...

Now it’s up to you to estimate the cost of manual fix of the errors. Seeing the quantity of them this is not something we wanna fix manually even incrementally or even one day. What I mean is that developers cannot, for budget or even human capacity all focus on this kind of task.

But could be set good practices to avoid introducing new legacy and also specific refactor/enhancement to allow a better global strictness.

7. My approach

Of course it’s really subjective and will depend on your business etc.
These are not all the existing entrypoint that can leverage global TypeScript enhancement.

7.1. Deadcode

Something we don’t wanna do is loosing time on fixing errors on unused code. Such as outdated mocks or so. To do so we can use great tools such as knip that will detect unused:

  • files
  • dependencies
  • exports
  • unused types and more !

Removing deadcode is in my opinion a pretty descent first step.

7.2. Freeze your business core

This might sound quite paradoxical due to TypeScript nature, but yes fixing ts-errors might bring functional regressions on stabilized bugs.
This is why it’s really important to take time to understand the section’s business logic you’re refactoring.
Also I would recommend, if it’s not already the case to implement user oriented tests, such as integration or e2e. To attest the application’s behavior before starting fixing ts-errors.
Of course even the best coverage won’t cover all possible blocks, such a refactor might always introduce regressions. It’s always important to notify QA about current technical improvements that might have an impact on the functional.

Note that unit tests might false positive, as you will have to fix type issues on them that can lead to previously untested behavior. Where as integration/e2e tests should not be requiring such a deep refactor if it’s not the mocked data nor server.

7.3. Refactor the contract API

One thing that can really be trouble source combined with too permissive tsconfig, in my opinion, are the models definition. By model I mean any incoming and outgoing data from and to external services, API libs etc. If you don’t know what’s in your app, you should be able to strictly define what’s coming ou or going in it.

Being able to rely on the outgoing data is good way to determine the final transpilation of your inner states before leaving your scope. It’s also a good way to avoid sending invalid or unhandled reqs bodies that could lead to undetermined api behavior.
In the same way, being able to rely on the oncoming data is always mind relaxing on rethinking your inner application state definition.

Model desync can come from several client side sources such as

  • Expecting a never received entry in the model
  • Making a typo either at the model declaration level, or while directly referencing a model instance entry.

For instance:

// User.model.ts
type User {
  id: string;
  name: string;
  birthdate: Date;
}

// UserProfile.tsx
// ...
const {id, name, birthDate} = props.user //birthDate will always be null
// ...

Of course this example is trivial, anyone testing its application will be able to analyze it pretty fast, this is a representation. Exist great tools that allow centralizing and transpiling any contract API through a e2e typesafe instance, that could between packages.

For handling legacy, or when you don’t have the hand on the back or maybe it’s just not a Node.js one. Zodios might be a good deal. Following this great codegen tool zodios-to-client that will create a Zodios instance for our front side based on the OpenApi specification of the API we’re dealing with.
This might really be a time gain/boost if you’re handling massive volume of endpoints, and good way to give it a try.
Note that it’s ship with Zod that could allow runtime type validation.

7.4. Good routine

When having existing legacy one of the main focus should be not appending new one.
In this way, as possible, defining new standardization with teammates is really necessary. Such as when creating anything from scratch, components models etc, make them strictly typed. When updating anything carrying legacy, as much as possible, either fix it or only add strictly typed updates.

Something anyone, when having to handle TypeScript legacy has been thinking about, is to attribute scoped rules to a specific folder. Such as lets say we’ve just refactored featureA our goal would be to apply a very strict tsconfig to its folder only.
With TypeScript Configuration Inheritance it’s possible !

In this way we could enable strict compilation on specific paths, that would also be checked through cicd across time. But we should keep in mind that it also has its limitations regarding features sharing or being dependent to each others.
Note that running a tsc compilation within your cicd not only within your framework build step, that might not even run type-check, is always a good practice to also cover tests files.

8. Conclusion

There’s not finite number of ways to approach such a TypeScript refactor. Can be notified JavaScript project migrating to a TypeScript one, where legacy would be incorrectly labelled.
Anyway these were, as of today, the main points I would focus on to enhance strictness of a TypeScript project with a too permissive configuration.
If you have any suggestion, questions or wanna share your approach I would be really interested, just ping me !

Thanks for reading