An approach to handle massive amounts of TypeScript legacy
20 jan 20241. Table of contents
-
- 5.1. “strict”
- 5.2. “strictNullChecks”
- 5.3. “strictPropertyInitialization”
- 5.4. “skipLibCheck”
-
- 7.1. Deadcode
- 7.2. Freeze your business core
- 7.3. Refactor the contract API
- 7.4. Good routine
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