DEV Community

Guillaume Martigny
Guillaume Martigny

Posted on

TypeScript is wasting my time

⚠️ 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)),
  });
});
Enter fullscreen mode Exit fullscreen mode

Playground

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);
}
Enter fullscreen mode Exit fullscreen mode

Playground

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 */);
};
Enter fullscreen mode Exit fullscreen mode

Playground

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);
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

Playground

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 😎

Discussion (37)

Collapse
peerreynders profile image
peerreynders • Edited on

Types you don't care about are unknown

export default (_: unknown, inject: Function) => {
  inject('myPlugin', /* Plugin code */);
};
Enter fullscreen mode Exit fullscreen mode

Use typeof type operator and ReturnType to generate the type for you.

function makeAPI(inject: Function) {
  return {
    get(key: string): object {
      /* Code code code */
      return {}; 
    },
    set(key: string, value: object): void {
      /* Code code code */
    }
  }
}

export type API = ReturnType<typeof makeAPI>;

export default (_: unknown, inject: Function) => {
  inject('myPlugin', makeAPI(inject));
};
Enter fullscreen mode Exit fullscreen mode

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:

  • type space: where TypeScript compile time types live
  • value space: where JavaScript runtime values live

The makeAPI function is pure JavaScript and lives in value space. With the help typeof and ReturnType we pulled API 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.


const ENTITIES = ['users', 'articles'] as const;
type Entities = typeof ENTITIES[number]; // "users" | "articles"

type BuilderStep = (this: URLBuilder, id?: string) => URLBuilder;
type URLBuilder = {
  currentUser: (this: URLBuilder) => URLBuilder;
  toString: () => string;
} & Record<Entities, BuilderStep>;

function API() {
  let url = 'api';
  const builder: Partial<URLBuilder> = {
    currentUser() {
      return this.users('4321');
    },
    toString() {
      return url;
    },
  };

  ENTITIES.forEach((entity) => {
    const fn: BuilderStep = function (id) {
      const idSegment = id ? `/${id}` : '';
      url += `/${entity}${idSegment}`;
      return this;
    };

    builder[entity] = fn;
  });

  return builder as URLBuilder;
}

// Usage
console.log(`${API().users('4321').articles()}`); // => 'api/users/4321/articles'
Enter fullscreen mode Exit fullscreen mode

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 that this will be a full blown URLBuilder 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 longer Partial but a fullblown URLBuilder.

Collapse
shalvah profile image
Shalvah

Love this reply.

  • Did not blame the dev or castigate him for "being ignorant".
  • Acknowledged that said tool is not perfect and needs to be handled a certain way
  • Provided solutions for the dev's problems
  • Provided links and explanations for those solutions

Top notch.👏👏👏

Collapse
dvddpl profile image
Davide de Paolis

Wow, wow, wow. Lots of useful advanced stuff here 🤩

Collapse
andrewbaisden profile image
Andrew Baisden

Super useful comment.

Collapse
capz profile image
Leroy

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

Collapse
gmartigny profile image
Guillaume Martigny Author

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.

Collapse
joelbonetr profile image
JoelBonetR • Edited on

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.

Collapse
joelbonetr profile image
JoelBonetR • Edited 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:

// @ts-check
const fs = require('fs');

/**
 * encodes a file into base64
 * @param {import('fs').PathOrFileDescriptor} file 
 * @returns {string}
 */
const base64_encode = (fileSource) => {
  /** @type {Buffer} */
  var file = fs.readFileSync(fileSource);
  return Buffer.from(file).toString('base64');
}
Enter fullscreen mode Exit fullscreen mode

If something bothers you (let's add an example):

// @ts-check
const { Sequelize } = require('sequelize');

const sequelize = new Sequelize(process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASSWORD, {
  host: process.env.DB_ADDR,
  dialect: process.env.DB_DIALECT,
});
Enter fullscreen mode Exit fullscreen mode

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:

// @ts-check
const { Sequelize } = require('sequelize');

const sequelize = new Sequelize(process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASSWORD, {
  host: process.env.DB_ADDR,
  // @ts-ignore
  dialect: process.env.DB_DIALECT,
});
Enter fullscreen mode Exit fullscreen mode

This way (using TS pragma + JSDoc) you will

  • Get your project well documented.
  • Get type checking where and when it's really meaningful and helpful.
  • Works in dev time, handled by VSCode out of the box without the need of adding TS as dependency in your project or configuring anything.
  • No time wasted compiling (transpiling) TS to JS.
  • Faster dev times.
  • No excuses for anyone not documenting the code with fallacies like "my code is good enough to be understood by anyone so it's auto-documented" or BS like that anymore. 😆
  • Get type inference for your variables and functions either be in the same file or imported/required from another.
  • Be happier (opinionated sentence).

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?

Collapse
gmartigny profile image
Guillaume Martigny Author

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.

Collapse
joelbonetr profile image
JoelBonetR

You just need to discern wherher is something verbose/unnecessary/absurd or something useful that must be provided 😂

Collapse
paratron profile image
Christian Engel • Edited on

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:

Object.keys(localStorage).forEach((key) => {
  store.commit('cache/init', {
    key,
    value: JSON.parse(localStorage.getItem(key)),
  });
});
Enter fullscreen mode Exit fullscreen mode

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:

function formatNumber(value: number, lang: string = 'en') {
  const options = {
    notation: 'compact',
    maximumFractionDigits: 1,
  };
  const formatter = Intl.NumberFormat(lang, options);
  return formatter.format(value);
}
Enter fullscreen mode Exit fullscreen mode

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. But string does not match the options wanted for NumberFormat. So what you need to do is:

function formatNumber(value: number, lang: string = 'en') {
  const options: {notation: "compact", maximumFractionDigits: number} = {
    notation: 'compact',
    maximumFractionDigits: 1,
  };
  const formatter = Intl.NumberFormat(lang, options);
  return formatter.format(value);
}
Enter fullscreen mode Exit fullscreen mode
Collapse
gmartigny profile image
Guillaume Martigny Author

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.

Collapse
lioness100 profile image
Lioness100 • Edited on

An easier way to do this is

const options = {
  ...
} as const;
Enter fullscreen mode Exit fullscreen mode

The "const assertion" will concrete the value to "compact" instead of string

Edit: oops, someone already mentioned that, sorry.

Collapse
fellz profile image
Roman

You should specify that notation not just the string type but concrete type Intl.NumberFormatOptions['notation']

  function formatNumber(value: number, lang: string = 'en') {
  const options = {
    notation: 'compact' as Intl.NumberFormatOptions['notation'],
    maximumFractionDigits: 1,
  };
   const formatter = Intl.NumberFormat(lang, options);
   return formatter.format(value);
}
Enter fullscreen mode Exit fullscreen mode
Collapse
adam_cyclones profile image
Adam Crockett

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.

Collapse
handsomebwonderful profile image
Mark Dochstader

THIS! There's a big difference between changing the extension of your files to .ts and getting to all the TSC flags where we get a lot of the benefits! I'm not suprised converting a project while learning the deeper aspects of the language is painful.
TypeScript tries hard to let you adopt it slower than a lot of its promoters want you to; it's completely OK to stop at parts of your codebase that don't line up well and just try for the easiest wins, then come back later (maybe!) and make incremental improvements.

Collapse
adam_cyclones profile image
Adam Crockett

It's even possible to just use checkjs and not use any Typescript files that's the bear 🐻 minimum.

Collapse
lukeshiru profile image
Luke Shiru

TypeScript is wasting my time.

Is better to take it as a time "investment". Is better to invest it in getting errors in dev, instead of actually wasting it dealing with errors in prod.

Is it null tho?

TS doesn't have a way of knowing that you're looping over valid properties of the localStorage object, it only knows that you're trying to access a string key of localStorage, which could be null, kinda like this:

localStorage.getItem(someRandomKey);
Enter fullscreen mode Exit fullscreen mode

You could deal with it by just having utils to get/set items from localStorage and parse them safely. Here's a Playground doing just that.

"compact" is not "compact"

"compact" is just a string, you have several ways of solving this problem. One is just to inline it. The other way is to use as const to make notation to be "compact" instead of string (playground here). And finally, you could use the type of the second argument to type options, like this, which has similar benefits to the first approach of auto-completion of properties.

Type IDontCare

It was pointed out already by other members of the community, but you have unknown for that (check it here). And I would also recommend not using Function as a type because is almost like using any for callbacks. Is better to specify the shape of the function you expect.

Code duplication

You can reduce duplication quite a bit there, by just not having your code inside the function:

// Write this once
const API = {
    get(key: string) {
        /* Code code code */
    },
    set(key: string, value: object) {
        /* Code code code */
    },
};

declare module "vue/types/vue" {
    interface Vue {
        $myPlugin: typeof API; // Use it wherever you want
    }
}

declare module "@nuxt/types" {
    interface NuxtAppOptions {
        $myPlugin: typeof API;
    }
    interface Context {
        $myPlugin: typeof API;
    }
}

declare module "vuex/types/index" {
    interface Store<S> {
        $myPlugin: typeof API;
    }
}

export default (_: unknown, inject: Function) => inject("myPlugin", API);
Enter fullscreen mode Exit fullscreen mode

From my PoV, the worst thing about this is coming from Vuex allowing you to define "globals" like that, but I guess that's how nuxt folks are used to work.

Import without the extension

This isn't a TS issue 100%. This is actually because Vite, WebPack, Rollup, and other bundlers need to know the extension of what you're importing to know what transform they should use, and TS needs that to figure out what you're trying to import. JS doesn't actually support importing files without an extension, that's all "node magic" (unless your package type is "module"), and even in Node you can only import JavaScript and JSON files, so you need a transformer to deal with that import.

Dynamic interface

Again, there are better ways of doing this, you can infer the types from your static content, you just need to type it a little bit better:

const API = () => {
    let url = "api";

    const entities = ["users", "articles" /* ... */] as const;

    const builder = {
        currentUser() {
            // Super hacky ... but we are using `this`, so anything goes!
            return (this as unknown as typeof entitiesObject).users("4321");
        },
        toString() {
            return url;
        },
    };

    const entityHandler = (entity: string) => (id?: string) => {
        url += `/${entity}${id ? `/${id}` : ""}`;
        return builder as Builder;
    };

    const entitiesObject = Object.fromEntries(
        entities.map(entity => [entity, entityHandler(entity)]),
    ) as Record<typeof entities[number], ReturnType<typeof entityHandler>>;

    type Builder = typeof builder & typeof entitiesObject;

    return Object.assign(builder, entitiesObject) as Builder;
};

const url = `${API().users("4321").articles()}`; // => 'api/users/4321/articles'
Enter fullscreen mode Exit fullscreen mode

You can try it here. Tho I would do it differently tbh (a more functional approach, using tagged templates).


Hope that helps. I agree with you TypeScript needs to improve their docs (they suck and is quite hard to find good sources for TS), but if you keep going you'll get the hang of it (in October this year I'll turn into a "10 years TS dev", trust me, it only gets better).

Cheers!

Collapse
gmartigny profile image
Guillaume Martigny Author

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.

Collapse
ryands17 profile image
Ryan Dsouza • Edited on

I haven't used Nuxt, but the first two can be simply done as follows:

  • Using the string 'null' as a backup like this: Playground

  • Using the exact type of what it expects: Playground or directly pass it in the function instead of declaring a new variable.

Collapse
gmartigny profile image
Guillaume Martigny Author

Maybe you should ! Come suffer with me ;)

Collapse
isaachagoel profile image
Isaac Hagoel

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.

Collapse
adxmcollins profile image
Adam Collins

It sounds like you’re frustrated because you are being held up by TypeScript trying to enforce sensible rules (I’m sure you will thank it at some point in future!)

As another commenter mentioned, you can reduce how strict those rules are in order to finish your migration.

I would suggest considering this, and then revisiting the things you are having difficulty with at a later stage once you have had the chance to learn more about TypeScript.

Good luck!

Collapse
jfbrennan profile image
Jordan Brennan

Hang in there, you'll get it...and then you'll wonder if it was even worth the effort because at the end of the day you have to ensure your app is solid at runtime and it's not TS that gets you there.

Collapse
brense profile image
Rense Bakker

Nothing wrong with learning.

Would not recommend to use the localStorage object like that in Typescript or plain Javascript. How can you be sure you want all the items stored on localstorage or that they will be JSON parsable? It's better to keep a list of localstorage keys for your app somewhere and loop through that, instead of all the keys on the localstorage object.

const cacheKeys = ['cache_item_one', 'cache_item_two', 'etc...']

cacheKeys.forEach(key => {
    const value = localStorage.getItem(key)
    if (value) {
        try {
            store.commit('cache/init', { key, value: JSON.parse(value) })
        } catch(e) {
            // wrap JSON.parse in a try catch block to avoid unexpected crashes
        }
    }
})
Enter fullscreen mode Exit fullscreen mode

For your string literals, try this:

const options = {
    notation: 'compact' as 'compact',
    maximumFractionDigits: 1,
};
Enter fullscreen mode Exit fullscreen mode

Your code duplication issue I'm not too familiar with Nuxt. Do they have Typescript documentation? If so, they'll usually explain how you're supposed to extend the built in interfaces.

No idea what you're trying to do with that URLBuilder =P

I don't think there's a hype around Typescript, but it is slowly growing because people appreciate type safety (or they learn to) and not having as many runtime errors.

Collapse
florianrappl profile image
Florian Rappl

I don't want to go into all of your issues. So I stop at the first one since no one mentioned this:

function formatNumber(value: number, lang: string = 'en') {
  const options = {
    notation: 'compact' as const,
    maximumFractionDigits: 1,
  };
  const formatter = Intl.NumberFormat(lang, options);
  return formatter.format(value);
}

Enter fullscreen mode Exit fullscreen mode

Solves your problem here. By default, TS makes / types these objects loosely, as such at it sees

interface TempInterface {
  notation: string;
  maximumFractionDigits: number;
}
Enter fullscreen mode Exit fullscreen mode

which makes sense as nothing prevents you to write options.notation = 'foo'. I think you fall into the trap of thinking that const 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.

Collapse
lukeshiru profile image
Luke Shiru

TypeScript is a superset of JS, meaning JS with some extra stuff ... CoffeeScript is an entirely different language that compiles to JS ... you are comparing apples to oranges. Not to mention that you can get the benefits of TS in plain JS with JSDocs 😅

Collapse
joehonton profile image
Joe Honton

I also think that TS takes the fun out of using JS.

Metaphorically, pure JS is a high-wire act without a safety net to catch you when you fall. Thrilling when you succeed. Possibly fatal when you don't.

Since I've mastered JS, I full well know which types of acrobatic stunts are the most dangerous. I code special scaffolding for those tricks, but prefer to stay high-flying without a safety net for all the easy stuff.

Collapse
boudewijn26 profile image
Boudewijn van Groos • Edited on

I'm a bit late to this party, but thought I'd have something to add.

Edit: on reflection my first point is a bit pedantic. I've tried to get 2 browser tabs to interfere in such a localStorage loop, but have so far been unable. It would seem localStorage synchronization happens asynchronously. One could make the very valid argument that during a debug you could change any value to anything, so bringing that up in regards to type safety is moot.

On localStorage: your statement "Even worse, I can't have unset values because I'm looping over existing items in the localStorage." is false.

localStorage.clear();
localStorage.setItem("a", "a");
localStorage.setItem("b", "b");
Object.keys(localStorage).forEach((key) => {
  console.log(localStorage.getItem(key));
  debugger; // delete the other localStorage entry in dev tools
});
Enter fullscreen mode Exit fullscreen mode

Will log "b" and null. While I would agree this is pedantic another situation this could occur is when you have one tab clearing localStorage entries while forEach is running in a second tab. Since the localStorage is shared between tabs it'd be allowed for your browser to process them simultaneously.

I would agree I'm being pedantic here, but what you're asking of Typescript is actually quite advanced. It would have to type narrow localStorage to a Record<K extends string, string> and let key be of K whilst understanding that forEach will preserve that narrowing. Typescript's flow analysis can't deal with lambdas very well, since it doesn't understand (yet?) that the lambda in forEach is synchronous and that calling forEach doesn't change any type narrowing. This would have to be a whole new Typescript feature to indicate that a function calls one or more lambda's synchronously (and maybe even in a given order) and it doesn't have any side effects that could change any type.

Luckily the fix is very simple:

Object.entries(localStorage).forEach(([key, value]) => {
  store.commit('cache/init', {
    key,
    value: JSON.parse(value),
  });
});
Enter fullscreen mode Exit fullscreen mode

This bypasses all your issues by having a single expression Object.entries(localStorage) contain both the keys and the values, then Typescript can work with the type of that expression and it doesn't have to understand the exact workings of forEach and localStorage

As for your dynamic interface: you are trying to get a static type checker to understand your dynamic interface. Hardship is to be anticipated. My suggestion would be as follows:

type EntityURLBuilder = (id?: string) => URLBuilder;
type URLBuilder = {
  currentUser(): URLBuilder;
  toString(): string;
  users: EntityURLBuilder;
  articles: EntityURLBuilder;
  // etc
};

const API = () => {
    let url = 'api';
    const buildFn = (entity: string) => {
      return function (this: URLBuilder, id?: string) {
        url += `/${entity}${id ? `/${id}` : ''}`;
        return this;
      }
    }

    return {
        currentUser() {
            return this.users('4321');
        },
        toString() {
            return url;
        },
        users: buildFn('users'),
        articles: buildFn('articles')
    };
};
console.log(API().users().articles().toString());
Enter fullscreen mode Exit fullscreen mode

Basically this would drop the dynamic part of it at the cost of some minor duplication.

This also solves the issue of changing the builder value. As soon as you have a variable declaration in Typescript, it assign a type to that variable based on the declaration. If you later change a structural bit of that variable, Typescript needs help figuring it out. In general typing is easier with immutable values as they can't, by definition, change their type. If you must have it dynamic I'd suggest this solution at the cost of one type cast

const ENTITIES = ['users', 'articles'] as const;
type Entity = typeof ENTITIES[number];
type EntityURLBuilder = (this: URLBuilder, id?: string) => URLBuilder;
type URLBuilder = Record<Entity, EntityURLBuilder> & {
  currentUser(): URLBuilder;
  toString(): string;
};

const API = (): URLBuilder => {
    let url = 'api';
    const buildFn = (entity: Entity) => {
      return function (this: URLBuilder, id?: string) {
        url += `/${entity}${id ? `/${id}` : ''}`;
        return this;
      };
    }
    return {
        currentUser() {
            return this.users('4321');
        },
        toString() {
            return url;
        },
        ...Object.fromEntries(ENTITIES.map((entity) => [entity, buildFn(entity)])) as Record<Entity, EntityURLBuilder>
    };
};
console.log(API().users().articles().toString());
Enter fullscreen mode Exit fullscreen mode
Collapse
martinmuzatko profile image
Martin Muzatko

Nuxt is very painful when it comes to declaring global types for inject, store, global pipes and prototype extensions. Unfortunately there is also no way to make that easier, as is the nature of vue. That is one of the reasons why I ditched Nuxt for Next.

Collapse
birdtho profile image
Christopher Thomas

You know you can adjust and manually relax eslint rules, some to warnings, others to off. Like the rules for implicit any.

Also casting types is good, like if you got the JSON.parse example, cast all your local storage.get() as string

I wouldn't use inject: (name: string, plugin: any) => void

Collapse
doublehub profile image
double v

Many of the points discussed find their roots in bad code design. Unfortunately, yes it's you. TypeScript is fine.

Collapse
urielsouza29 profile image
Uriel dos Santos Souza