⚠️ This is a bit of a rant as I've lost patience with TypeScript and I need to vent.
While converting a medium sized Nuxt application (~15 pages, i18n, auth, REST API) to TypeScript, I compiled a list of pain points (no specific order). This is not the first time that TS made me miserable while trying to use it. Maybe this is a "me" problem and I lack knowledge or skills. But, if this is the case, I bet that a lot of new developers also hit these roadblocks and didn't say anything because of the hype surrounding TS.
Is it null
tho ?
Consider this simple "cache initialization" code:
Object.keys(localStorage).forEach((key) => {
store.commit('cache/init', {
key,
value: JSON.parse(localStorage.getItem(key)),
});
});
It iterate over all items in localStorage
and send the parsed value to the store.
Here, I get an error under localStorage.getItem(key)
because JSON.parse
accept string
as its first argument and localStorage.getItem
can return null
. Ok ... but ... whatever dude ! JSON.parse(null)
doesn't even produce an error. Even worse, I can't have unset values because I'm looping over existing items in the localStorage
.
"compact"
is not "compact"
Consider this number formater code:
function formatNumber(value: number, lang: string = 'en') {
const options = {
notation: 'compact',
maximumFractionDigits: 1,
};
const formatter = Intl.NumberFormat(lang, options);
return formatter.format(value);
}
The options
parameter is underlined with an error because the field notation
is a string when it should be "compact" | "standard" | "scientific" | "engineering" | undefined
. Well ... it's hardcoded to "compact"
, which is pretty close to "compact"
to me.
Type IDontCare
Consider this plugin declaration in Nuxt:
export default (_, inject: Function) => {
inject('myPlugin', /* Plugin code */);
};
In Nuxt, plugins are called with 2 parameters. The first is the Nuxt context, the second is a function that add the plugin to said context.
Here, I don't use the context, so set it to _
(as per the recommendations). But I get an error because it has an implicit type any
. I mean ... right, but who cares ? I'm not using this parameter. I have specifically renamed it to inform that I don't use it. Why does it reports as an error ?
Code duplication !
This one is pretty nasty to me. Again, consider a plugin declaration in Nuxt. This plugin expose a set of function.
export default (_: DontCare, inject: Function) => {
const API = {
get(key: string): object { /* Code code code */ }
set(key: string, value: object): void { /* Code code code */ }
};
inject('myPlugin', API);
};
Everything's good until there. Now, I want to use it in my code. I have to declare the injected function in every possible place.
interface API {
get(key: string): object
set(key: string, value: object): void
}
declare module 'vue/types/vue' {
// this.$myPlugin inside Vue components
interface Vue {
$myPlugin: API
}
}
declare module '@nuxt/types' {
// nuxtContext.app.$myPlugin inside asyncData, fetch, plugins, middleware, nuxtServerInit
interface NuxtAppOptions {
$myPlugin: API
}
// nuxtContext.$myPlugin
interface Context {
$myPlugin: API
}
}
declare module 'vuex/types/index' {
// this.$myPlugin inside Vuex stores
interface Store<S> {
$myPlugin: API
}
}
export default (_: DontCare, inject: Function) => {
const API: API = {
get(key) { /* Code code code */ }
set(key, value) { /* Code code code */ }
};
inject('myPlugin', API);
};
The worst part is not even that I have to tell TS that Nuxt is injecting my plugin everywhere. The worst part is that I have to make sure that every function signature in the plugin match with the interface. Why can't I infer types from the API itself ? Also, ctrl + click
become useless as it points to the interface and not the implementation (maybe an IDE issue, but still ...).
The cherry on top is that now, ESlint is pouting because function params in the interface are considered unused.
Import without the extension
TS need the file extension to detect the file type and compile accordingly. Fair enough, but now I have to go through all my import and add .vue
everywhere.
Dynamic interface
I have an URL builder that I can chain call to append to a string.
const API = () => {
let url = 'api';
const builder = {
currentUser() {
return this.users('4321');
},
toString() {
return url;
}
};
['users', 'articles', /* ... */].forEach((entity) => {
builder[entity] = (id) => {
url += `/${entity}${id ? `/${id}` : ''}`;
return builder;
};
});
};
// Usage
const url = `${API().users('4321').articles()}`; // => 'api/users/4321/articles'
This is fine and dandy until TS coming shrieking. I can declare a type listing my entities and use this type as key in a Record
(see Code duplication !). But I also need to describe the toString
and currentUser
methods aside.
type Entities = 'users' | 'articles' | /* ... */;
type URLBuilder = Record<Entities, (id?: string) => URLBuilder> & {
currentUser(): URLBuilder
toString(): string
};
const API = () => {
let url = 'api';
const builder: URLBuilder = {
currentUser() {
return this.users('4321');
},
toString() {
return url;
}
};
const entities: Entities[] = ['users', 'articles'];
entities.forEach((entity) => {
builder[entity] = function (id?: string) {
url += `/${entity}${id ? `/${id}` : ''}`;
return this;
}
});
return builder;
};
Problem solved ? Not quite ... The temporary builder initialized while building the whole thing is not yet of type URLBuilder
. I have no idea how to say "This will be of type T in a few lines".
Conclusion
I'm absolutely sure that all those issues are due to some lack of knowledge. If you have an elegant solution for any of those, please share in the comments.
Microsoft is not investing so much energy in something that's wasting time. I would love to come back to this article in a few years and finding all of this ridiculous, but for now, I really don't get the hype around Typescript.
Thanks for indulging me 😎
Top comments (28)
Types you don't care about are unknown
Use typeof type operator and ReturnType to generate the type for you.
Other than that it's just the "cost of doing business". Those interfaces had no idea that you would be adding your plugin. So now you are merging your plugin into those existing interfaces.
Also it's not duplication. There is type declaration space and variable declaration space or as I like to put it:
The
makeAPI
function is pure JavaScript and lives in value space. With the helptypeof
andReturnType
we pulledAPI
into type space for compile time type checking. Monkey patching those interfaces happens at runtime in value space, so TypeScript can't really track where it is going to end up - yet value space code is going to try to access it in those places so it becomes necessary to tell TypeScript where it is going to show up.TypeScript is a compile time static type checker. Here you are assembling an object dynamically at runtime (in value space). TypeScript hates that - so you have to take TypeScript by the hand and explain to it like it's five.
The big help here is
Partial<URLBuilder>
because it makes all the members of the object optional so things can be added piece by piece. However for methods we have to assert thatthis
will be a full blownURLBuilder
by the time the method runs.In the end you as the developer have to take the responsibility of asserting that you are sure that
builder
is no longerPartial
but a fullblownURLBuilder
.Love this reply.
Top notch.👏👏👏
Wow, wow, wow. Lots of useful advanced stuff here 🤩
Super useful comment.
Sorry to say but yes it's you. Typescript is a superset of js so you could just rename the files, turn down the strict mess of your linter and incrementally turn your codebase into more of a proper ts codebase.
Some of the challenges you write about can be solved with a simple Google search.
Using anonymous types really doesn't help. Your compact issue for example, and the url builder return type (which is missing, btw).
It's a little bit of time invested now, but easily recouped by preventing bugs before they occur while writing code in the future. I've upgraded a large corp application and it was a pain as well, but well worth it. Using a good ide like webstorm can take some of the tedious work out of your hands, so is highly recommendable
You mention that while upgrading a large application code-base, it was worth it. Could you give a few examples of what are the best, real life, advantages ?
Also, I'm using Webstorm. I'm greatly in love with it and it surely helped a lot.
I was using Jetbrains IDEs as well in the past but nowadays I need to say that VSCode is the top tier IDE for many languages.
The first day was hard to me due to keybindings so I added a pluggin called intelliJ Idea Keybindings, edited the comment block one (I'm with a TKL keyboard) and I'm happy since then 😆
It consumes way less RAM and CPU, has built-in features while working with JS that webstorm lacks, it's more customizable and so on.
After working with both we ended up with a middle point solution that has become our favourite, and it's using TS + JS but not in separate files, let me explain:
You can just add TS Pragma at the top of your JS files
// @ts-check
Then declare the types you want with JSDoc
Quick example:
If something bothers you (let's add an example):
It will complain about
Type 'string' is not assignable to type 'Dialect'.ts(2322)
in the dialect option.Do I really need to create a Dialect for that when a string stored in a config/env file will do the exact same job?
I mean, I'm not going to suddenly use a different dialect on the DB, it will be the same always for this project and if I need to change something its just about editing the config/env file, migrate the models, migrate the current data... it's not something you do "by mistake" or whatever, you really need to invest time on it.
Moreover I'm not working with POO which means no custom classes are (nor will be) added to the project.
Quick fix:
This way (using TS pragma + JSDoc) you will
It's a win-win, the best of both worlds and (to me) the way to go..
We already tried it in a big project that is in production since 5 to 6 months ago (at the time of writing that).
We had 2 webapps, one with JS another one using TS and this third one using the approach described in this comment.
Now, no one in the team wants TS anymore, that was unexpected at the beginning but we're getting faster development times, the code is much more understandable by anyone new in the project, no flaws due to types, makes PR review easier and so on.
We're thinking to migrate the TS one to this approach first while adding this approach in the JS one anytime we need to edit a file, find a bug, add a feature and so on.
Thoughts on that? Have you ever tried this approach?
I love JSDoc and use it everywhere I can. In a personal project I use JSDoc to output types for users to consume. This is a good tradeoff for me, because I don't have to bother with TS, but my users can have their types if they need to.
I'll try your method, but I'm worry I won't like putting
@ts-ignore
everywhere.You just need to discern wherher is something verbose/unnecessary/absurd or something useful that must be provided 😂
I cursed a lot when I started to use typescript as well. I felt as if someone tied rocks to my hands when I was able to write perfectly working JS before.
Soon you will adapt and naturally write code that gets along easier with TS. This is not exactly a drawback. Maybe it gets less elegant here and there, but its mostly for the better, trust me. Where you are, I have been - where I am, you will be :D
I don't have time to go into every single of your examples, but at least the first two:
TSC does a lot of code checking but it has its limits. It does not know that when you call
localStorage.getItem(key)
, the key HAS to be present because its derived from the current keys in localStorage. To mitigate this, you can give the TSC a hint that a given value WILL be there by adding an exclamation mark:JSON.parse(localStorage.getItem(key)!)
This is somewhat the same problem:
TSC sees: "ah, he assigned a string here" and internally derives the type of "options" to
{notation: string, maximumFractionDigits: number}
. He is not exactly incorrect here. Butstring
does not match the options wanted for NumberFormat. So what you need to do is:As stated 2 times, I know this is mostly due to a lack of knowledge. So thanks a lot for the encouragements. I'm fully aware that a project with 80K stars and 33K commits over 650 contributors is not a failure.
Also, thanks for the two advices. BTW, @ryands17 taught me that
Intl.NumberFormatOptions
is an existing type.An easier way to do this is
The "const assertion" will concrete the value to "compact" instead of string
Edit: oops, someone already mentioned that, sorry.
You should specify that
notation
not just thestring
type but concrete typeIntl.NumberFormatOptions['notation']
Having to convert a project is always a headache no matter what it is to what it will be. I have been recommending to customers to make a new major version and port to typescript scaffolding from scratch with typescript and slotting in the core bits to suit.
I haven't used Nuxt, but the first two can be simply done as follows:
Using the string
'null'
as a backup like this: PlaygroundUsing the exact type of what it expects: Playground or directly pass it in the function instead of declaring a new variable.
I suggest you trust your own reasoning and intuition more. TS is cool and all but it is not the silver bullet that the dogmatic part of its community makes it out to be. Most importantly it does not come without tradeoffs or costs (like everthing else). For some projects and some programmers the benefits are larger than the costs but it is not always the case. Should you learn it? By all means yes. Should you use it? When it's appropriate. Should you adopt a victim mentality and worship at its feet? No way sir.
Maybe you should ! Come suffer with me ;)
Thanks a lot for taking the time to address every points. I really want to understand why so many are enthralled by TS. Be sure that on monday, I'll be going back to this with your help.
I don't want to go into all of your issues. So I stop at the first one since no one mentioned this:
Solves your problem here. By default, TS makes / types these objects loosely, as such at it sees
which makes sense as nothing prevents you to write
options.notation = 'foo'
. I think you fall into the trap of thinking thatconst
means immutable, but it rather means "cannot be reassigned".The alternative to help TypeScript here with the
as const
is to just tell it the right interface in the assignment.