DEV Community

Discussion on: TypeScript is wasting my time

Collapse
 
boudewijn26 profile image
Boudewijn van Groos • Edited

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