0
const assets = {
  mapTileset: '',
  characterTileset: '',
};
const totalAssetCount = Object.keys(assets).length;

type assetKey = keyof typeof assets;

export class AssetLoader {
  assets: Record<assetKey, HTMLImageElement>;
  loadedCount = 0;
  onAssetsLoaded: VoidFunction;

  constructor(onLoaded: VoidFunction) {
    this.onAssetsLoaded = onLoaded;
    const assetMap = {} as Record<assetKey, HTMLImageElement>;

    Object.entries<string>(assets).forEach(([key, assetSrc]) => {
      const asset = new Image();
      asset.onload = this.onLoad;
      asset.src = assetSrc;
      assetMap[key as assetKey] = asset;
    });

    this.assets = assetMap;
  }

  private onLoad = () => {
    this.loadedCount++;
    if (this.loadedCount === totalAssetCount) {
      this.onAssetsLoaded();
    }
  };
}

I want to have typed assets map where keys are limited to the ones specified in another object.

Is there a way to write it without "as" in constructor?

Or any better way to write such code that will map {key: assetSource} -> {key: loadedImage} while keeping track of how many is already loaded

Naszos
  • 260
  • 1
  • 10

1 Answers1

1

TL;DR: Use as or write a redundant array of keys.


The TypeScript standard library's typing for Object.entries(obj), like its typing for Object.keys(obj), returns keys of type string, and not keyof typeof obj. This is frustrating to many people, but it works that way for an important reason: object types in TypeScript are open or extendible, and not closed or exact.

Consider the following code:

interface Foo {
  x: string;
}
interface Bar extends Foo {
  y: number;
}
const b: Bar = { x: "", y: 1 };
const f: Foo = b; // okay
f.y; // error, y is not known to exist on Foo
console.log(Object.entries(f)); // [["x",""],["y",1]];

An object of type Foo is known to have a string-valued x property, but it is not known to lack other properties. Since Bar extends Foo, it means every Bar is a valid Foo, so there could be some Foo objects with a number-valued y property. Or really, any other unspecified property. The only property of a Foo object that we know anything for certain about is x. So Object.keys(f) needs to return an array of at least "x", and maybe all kinds of other things. So that becomes just string.


There's not much to be done here, then. The compiler doesn't have a representation for types that it knows have no extra properties. Its excess property checking functionality only works for object literals that have not yet been assigned anywhere. Once you have assigned it to the assets variable, the compiler treats it like any other object-typed variable. It has mapTileset and characterTileset keys, and maybe all sorts of other keys.

If you want to tell the compiler "no, it's just those keys", then you can use a type assertion; either the way you have done, or at the Object.entries() call:

(Object.entries(assets) as [assetKey, string][]).forEach(([key, assetSrc]) => {
  const asset = new Image();
  asset.onload = this.onLoad;
  asset.src = assetSrc;
  assetMap[key] = asset;
});

They're both the same thing, though: you telling the compiler what it can't figure out for itself.

The other way to proceed is to make up your own array of known keys, like this:

(["mapTileset", "characterTileset"] as const).forEach(key => {
  const asset = new Image();
  asset.onload = this.onLoad;
  asset.src = assets[key];
  assetMap[key] = asset;
});

This doesn't use a type assertion at all, really. The as const there is called a const assertion and asks the compiler to infer the array not as string[], but as a readonly pair of string literals. The code works without a problem and is arguably safer than your other code: if assets somehow does get other properties, you won't accidentally use them... but you pay for this safety by having to write a redundant array.

Usually I just use a type assertion, and move on.


Okay, hope that helps; good luck!

Playground link

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Great explanation, thanks! After reading this I ended up writing helper function that just returns keys of T so I can reuse that kind of Object.keys later – Naszos Aug 20 '20 at 17:27