I started my JS career in 2015, spent a year working exclusively with it, and then transitioned to TypeScript. I’d love to say 'And never looked back again!', but I can’t; since 2016 there’s a chore I have to do basically at every company I worked for: converting existing codebases from Javascript to TypeScript. These are going to be my subjective, sometimes scolding opinions.
In a hurry? Here’s the tl;dr:
- The False Sense of Velocity: skipping to deal with data drifting and edge cases is surely faster than dealing with them, but the price will be paid nonetheless.
- Data Drifting: When data schemas evolve without a type system following them, it’s not just migration to TypeScript that gets hard, you will be plagued by support issues. Best way to deal with it is to standardize first your data collections and have strict typing afterwards.
- Poisonous Optionals: When your data schema lacks uniformity, you would have to deal with handling of undefined properties every time you deal with these. TS is just a reminder, not the cause for it.
-
any
Means Trouble: Using any hides potential issues and makes it harder to track your actual state of TS migration; define first your API types, and type inference will lay out a migration “plan”. -
Forgetting about Promises: Forgetting to react to promises either with
await
or with.then/.catch
happens more often than we think. Failing to do so might cause you long hours of investigating weird bugs like an entry created for a missing photo. - Undefined Variables: A classic refactoring mistake in JavaScript when you rename/move out things from scope, but not all references to them get updated; TypeScript helps catch these before runtime.
- Unsafe Array Operations: It’s quite easy to forget about edge cases of arrays; yet another area where TypeScript helps prevent this.
- The Saga of Magic Code: Complex JavaScript patterns can be very hard to type; but do you actually need them, are they good, recognizable patterns?
The False Sense of Velocity
Every now and then I encounter a JS purist, who sneers at us with their harsh opinion: they simply work way too fast to get slowed down by the extra steps required for TypeScript. Sounds badass, but this confidence usually falls apart when I change the extension from .js
to .ts
, because I get blasted by the following typical errors.
Data drifting
I wish to start with this, as I rarely read about it in blogs, but it is, in my opinion, one of the hardest parts of TS conversion: the authors start with data having ShapeA
a couple of years before the conversion. Then ShapeA
changes to ShapeB
, ShapeC
, then there are competing versions for ShapeD1
and ShapeD2
. In the end you will have all shapes of the data in your (probably NoSQL) database accumulated during many years: [ShapeA, ShapeD1, ShapeD1, ShapeB, ShapeD2, ShapeB, …]
.
An example: recently I had sampled from a Firestore collection items of users. The birthday
property had the following types:
type User = {
// …
birthday?: string | Date | { time: string } | null;
}
Of course if I use this type that accurately describes what is in the database the entire codebase goes up in flames: hundreds of TS compilation errors show up, while the morale goes down. Usually it’s warning me about potentially undefined
values, or missing conversions (e.g. a string
is not a Date
).
Not dealing with this toxic variability, a developer might feel very productive, but these are going to cause a lot of trouble to customers, customer support and data analysts (maybe we should have an annual “Hug and comfort a data analyst, who puts up with your data leniency” day so we don't forget about these folks?). We waste their time, but us? We’re fast as lightning without the confines of strict types.
After trying to solve it in through million different ways, I reckon the only way of dealing with this is to...
- first, define the ideal type you want to work with
- and second: standardize the data you have accordingly (which means backing up and sweaty scripting of long running tasks)
Poisonous optionals
If you have paid attention to how the example type was defined you would see how this a problem directly related to data drifting:
There can be a million different ways an expected property to be not in an object.
In a JS codebase (especially if you choose a simple text editor over an IDE) you are not going to get warned about these, since really, there is now way for your IDE to tell if an object passed in as a parameter has or hasn’t the expected property. However TS will warn you about these missing optionals (i.e. not just you might not have birthday.__time__
you might not even have a birthday
property) and then you would be forced to cover all these cases (including null
as opposed to undefined
- take a second look at the type above 🫣). This is usually means to do the repetitive task of writing birthday?.time ? birthDay.time : typeof birthDay === ‘string’ ? new Date(birthday) : birthDay, etc.
(of course not in this brainf*** format).
This often leads to a lot of frustration, and not just to devs new to TS. It does feel like having types slow us down.
But in reality this is the consequence of data drifting: whose fault is that you cannot be ever sure whether an object has a birthday property?
How any
gets in the way of TS migration
I have a clear preference for where to start in a project to do the TypeScript migration and it is where external data enters; either through fetch or through a form.
Let's say that for a given piece of data I have 3 relevant files:
-
fetch-data.js
where the data gets queried from an endpoint -
ParentComponent.js
where data is partially transformed and passed as a prop to the next component -
ChildComponent.js
where the passed data fromParentComponent.js
is used
Now if during migration I choose to convert to ParentComponent.js
and as I don't know what exact data is fetched in fetch-data.js
I use any
temporarily to silence the annoying compiler, I might set myself up for failure.
Of course, if we had these 3 files clearly laid out to us we would not start with ParentComponent.js
, but in real-world we would have hundreds of JS files and when a bug occurs in ParentComponent.js
we feel tempted to convert it to TypeScript.
In this case the following happens:
- exact shapes of data in
fetch-data.js
stays unknown -
ParentComponent.tsx
uses it withany
-
ChildComponent.js
receives it and it's going to be anunknown
as well
Now a bit later someone else adds the types to both fetch-data.js
and ChildComponent.js
. Looking at the extension it seems ParentComponent.tsx
got migrated and trusted, but in reality the relevant data for ChildComponent.tsx
would be still unknown
but in this case we would not even know about it. The type inference would break in ParentComponent.tsx
and we are still poking in the darkness in ChildComponent.tsx
.
In my opinion this case is worse than having the component completely untyped as I would know to tread more carefully.
To deal with this, I suggest starting from converting the files where data is originating from, which is going to be where the data is fetched or where it is produced through a form.
TypeScript is great at inferring these schemas (unless you do the magic code; see below), so when you incrementally change your codebase you can then rely on these sources being correct.
Order grows out from the seeds of well-defined data sowed at the entry of your data flow.
A real world example is when I finally knew what a validationError
object looked like, I could correct a bug of a label for a validation error never showing up, e.g. the component code expected a singular validationErrors.article
property, but the actual property was validationErros.articles
. This is a super easy thing to miss.
Note, that it was for a reason I have mentioned data drifting and data standardization: if you do these 2 steps correctly, now you are going to deal with robust types with very little ambiguity.
Forgetting about Promises
Let’s say your block of code might receive an image, convert it to another image format, save it to storage and create an entry for it. However, saving the image never gets awaited, and the code always goes to the next step, which is saving the data that might point to a non-existent storage location. Local testing does not find it, only some users complain that sometimes their uploaded photos disappear.
While I haven’t encountered a developer who is fine with this, with JS codebases it happens way more often than people think.
Undefined variables, unsafe array or object operations, optional everything
Recently I have found an ironic bug: an error never got reported as the error message used a variable that was defined in a different scope. No errors, no problem!
As I said, it wastes so much time, because the error might get to you way later when you accidentally stumble upon it in the error logs of your container. Or it’s customers who report it.
This is, just like “forgetting about promises” is a very frequent issue of JS codebases. It’s not a “developer issue”. It usually happens under certain circumstances, for example during a mentally taxing refactoring step: you move code around a lot, and then a variable like this slips through. (Obviously a good test coverage can spot these; that’s for another time.)
Even if you work with very disciplined developers, some errors are just very hard to spot by looking at them, therefore people frequently forget about them. For example there are unsafe array operations: for example you want to grab the title of the first item in an array (articles[0].title
) there can be cases when the query returns 0 items, therefore articles[0]
will be undefined
.
Again, this is an everyday, average slip, it’s not a “character issue” of an (otherwise great) engineer. But we have a tool to warn us, and that’s TypeScript.
The Saga of Magic Code That Could Not Have Been Typed
Finally there is a situation where the “inconvenience of TypeScript” can actually help you write simpler code through making convoluted code painful to write; I will explain what I mean by this later.
The flexibility of JavaScript allows transformations (e.g. add properties to a function
) that are extremely complicated to accurately type. I remember a magic function transformer that took a function, stapled variables from a redux store onto it binding the parameters and redux actions and the function together.
The problem was that this custom piece of code was very hard to understand and took a lot of time to for every team member to get it. Contrary to this, working along coding standards saves a lot of time for engineers of an organization: any member from any team is now going to be able to deal with the code without needing to spend time and familiarize themselves with the inner workings of a contraption.
I want to say redux-saga
is a similar case because of the usage of generator functions: there is an error by design there, I must mention. I have found typing these generator functions to be extremely hard. Try typing a saga (and using it in another TypeScript file) that for every yields might return a different value type. First an undefined
value (as in no explicit returned value), then a Promise
and finally an explicit value (say the third yield returns a sanitized user
object).
One can argue that typing this and dealing with its ambiguity is just too much effort, therefore TypeScript puts shackles on the brilliant mind of a competent Javascript programmer, but my counter-argument is why do we need this flexibility?
I mentioned “inconvenience” at the start of this paragraph: since most devs who work with TS are just not that level to be able to type these contraptions, they are not going to write these magic functions; which in my opinion is a very good thing.
Writing an extremely complicated function should be only a last resort anyways, after exhausting all simpler options. If TypeScript makes it harder, that’s even better.
Conclusion
In my professional experience, fuzzy, heterogenous sources of data and broken chains of data flow cause the most issues in a TypeScript conversion, however I argue that it is not TS being at fault here; it is the toxic flexibility of JavaScript that delays dealing with the consequences of coding mistakes to runtime.
I sometimes call it “HDD”: hope driven development.
You hope that the data would look like how you’d expect it to look like. You hope that the unit tests are describing the data accurately and you won’t have false positives. You hope it will blow up before it blows up at the user.
While it is exhausting to deal with these errors, it is way cheaper to address these during compile time than waiting until accidents happen to the customers. And for the entire company!
With a solid TS codebase you will unlock your true velocity because then you can rely on your data and the tooling around it.