最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

javascript - Preserve Type when using Object.entries - Stack Overflow

programmeradmin3浏览0评论

I'm fairly new to TypeScript, so I'm in the process of upgrading my old projects to utilize it.

However, I'm not sure how to preserve the correct Type when calling Object.entries on some data.

CodeSandbox example

As an example:

Level.tsx:

  const UnpassableTileComponents = useMemo(() => 
    Object.entries(levelData[`level_${gameLevel}`].tiles.unpassable_tiles).map(([tileType, tiles]) => (
      tiles.map(([leftPos, topPos], index) => (
        <UnpassableTile
          key={`${tileType}_${index}`}
          leftPos={leftPos * 40}
          topPos={topPos * 40}
          tileType={tileType}
        />
      ))
    )
  ).flat(), [gameLevel])

levelData.tsx:

import levelJSON from "./levelJSON.json";

interface ILevelJSON {
  [key: string]: Level;
}

interface Level {
  tiles: Tiles;
}

interface Tiles {
  unpassable_tiles: UnpassableTiles;
}

interface UnpassableTiles {
  rock: Array<number[]>;
  tree: Array<number[]>;
}

export default levelJSON as ILevelJSON;

levelJSON.json:

{
  "level_1": {
    "tiles": {
      "unpassable_tiles": {
        "rock": [[0, 0]],
        "tree": [[2, 0]]
      }
    }
  },
  "level_2": {
    "tiles": {
      "unpassable_tiles": {
        "rock": [[]],
        "tree": [[]]
      }
    }
  }
}

In the case of the above, tiles represents an Array of arrays, each with two numbers. Therefore, [leftPos, topPos] should both be typed as number. However, in Level.tsx, they have properties of any. I could get my desired result with the following:

  const UnpassableTileComponents = useMemo(() => 
    Object.entries(levelData[`level_${gameLevel}`].tiles.unpassable_tiles).map(([tileType, tiles]) => (
      tiles.map(([leftPos, topPos] : number[], index: number) => (
        <UnpassableTile
          key={`${tileType}_${index}`}
          leftPos={leftPos * 40}
          topPos={topPos * 40}
          tileType={tileType}
        />
      ))

But shouldn't number[] be inferred anyways?

Any advice would be appreciated.

I'm fairly new to TypeScript, so I'm in the process of upgrading my old projects to utilize it.

However, I'm not sure how to preserve the correct Type when calling Object.entries on some data.

CodeSandbox example

As an example:

Level.tsx:

  const UnpassableTileComponents = useMemo(() => 
    Object.entries(levelData[`level_${gameLevel}`].tiles.unpassable_tiles).map(([tileType, tiles]) => (
      tiles.map(([leftPos, topPos], index) => (
        <UnpassableTile
          key={`${tileType}_${index}`}
          leftPos={leftPos * 40}
          topPos={topPos * 40}
          tileType={tileType}
        />
      ))
    )
  ).flat(), [gameLevel])

levelData.tsx:

import levelJSON from "./levelJSON.json";

interface ILevelJSON {
  [key: string]: Level;
}

interface Level {
  tiles: Tiles;
}

interface Tiles {
  unpassable_tiles: UnpassableTiles;
}

interface UnpassableTiles {
  rock: Array<number[]>;
  tree: Array<number[]>;
}

export default levelJSON as ILevelJSON;

levelJSON.json:

{
  "level_1": {
    "tiles": {
      "unpassable_tiles": {
        "rock": [[0, 0]],
        "tree": [[2, 0]]
      }
    }
  },
  "level_2": {
    "tiles": {
      "unpassable_tiles": {
        "rock": [[]],
        "tree": [[]]
      }
    }
  }
}

In the case of the above, tiles represents an Array of arrays, each with two numbers. Therefore, [leftPos, topPos] should both be typed as number. However, in Level.tsx, they have properties of any. I could get my desired result with the following:

  const UnpassableTileComponents = useMemo(() => 
    Object.entries(levelData[`level_${gameLevel}`].tiles.unpassable_tiles).map(([tileType, tiles]) => (
      tiles.map(([leftPos, topPos] : number[], index: number) => (
        <UnpassableTile
          key={`${tileType}_${index}`}
          leftPos={leftPos * 40}
          topPos={topPos * 40}
          tileType={tileType}
        />
      ))

But shouldn't number[] be inferred anyways?

Any advice would be appreciated.

Share Improve this question edited May 27, 2020 at 23:06 UK_Kefka asked May 27, 2020 at 22:14 UK_KefkaUK_Kefka 2511 gold badge3 silver badges8 bronze badges 3
  • Welcome to Stack Overflow! Please consider modifying the above code to constitute a minimal reproducible example as mentioned in How to Ask. Ideally something that could be dropped into a standalone IDE like The TypeScript Playground and allow people who want to help to demonstrate your issue for themselves. Good luck! – jcalz Commented May 27, 2020 at 22:28
  • 1 @jcalz Added as requested :) – UK_Kefka Commented May 27, 2020 at 23:07
  • This question is similar to stackoverflow.com/q/70420283/990642 – josephdpurcell Commented Aug 19, 2022 at 15:03
Add a comment  | 

2 Answers 2

Reset to default 16

This is related to questions like Why doesn't Object.keys return a keyof type in TypeScript?. The answer to both is that object types in TypeScript are not exact (see microsoft/TypeScript#12936); values of object types are allowed to extra properties not known about by the compiler. This allows interface and class inheritance, which is very useful. But it can lead to confusion.

For example, if I have a value nameHaver of type {name: string}, I know it has a name property, but I don't know that it only has a name property. So I can't say that Object.entries(nameHaver) will be Array<["name", string]>:

interface NameHaver { name: string }
declare const nameHaver: NameHaver;
const entries: Array<["name", string]> = Object.entries(nameHaver); // error here: why?
entries.map(([k, v]) => v.toUpperCase()); 

What if nameHaver has more than just a name property, as in:

interface NameHaver { name: string }
class Person implements NameHaver { constructor(public name: string, public age: number) { } }
const nameHaver: NameHaver = new Person("Alice", 35);
const entries: Array<["name", string]> = Object.entries(nameHaver); // error here: ohhh
entries.map(([k, v]) => v.toUpperCase());  // explodes at runtime!

Oops. We assumed that nameHaver's values were always string, but one is a number, which will not be happy with toUpperCase(). The only safe thing to assume that Object.entries() produces is Array<[string, unknown]> (although the standard library uses Array<[string, any]> instead).


So what can we do? Well, if you happen to know and are absolutely sure that a value has only the keys known about by the compiler, then you can write your own typing for Object.entries() and use that instead... and you need to be very careful with it. Here's one possible typing:

type Entries<T> = { [K in keyof T]: [K, T[K]] }[keyof T];
function ObjectEntries<T extends object>(t: T): Entries<T>[] {
  return Object.entries(t) as any;
}

The as any is a type assertion that suppresses the normal complaint about Object.entries(). The type Entries<T> is a mapped type that we immediately look up to produce a union of the known entries:

const entries = ObjectEntries(nameHaver);
// const entries: ["name", string][]

That is the same type I manually wrote before for entries. If you use ObjectEntries instead of Object.entries in your code, it should "fix" your issue. But do keep in mind you are relying on the fact that the object whose entries you are iterating has no unknown extra properties. If it ever becomes the case that someone adds an extra property of a non-number[] type to unpassable_tiles, you might have a problem at runtime.


Playground link to code

@jcalz's excellent answer explains why what you are trying to do is so tricky. His approach could work if you want to keep your underlying schemas and JSON the same. But I will point out that you can sidestep the entire problem just by structuring your data differently. I think that will make your developer experience, as well as the clarify of what your data is, better.

One of the fundamental problems you're having is that you're trying to treat a map of key: value pairs as, in your case, some sort of list of impassable tiles. But it is inherently unwieldy and confusing to work with Object.entries just to get at your impassable tile types.

Why not define ImpassableTile as a type, and the list of impassable tiles as an array of that type? That better matches, conceptually, what the data actually represents. It also sidesteps Object.entries and its difficulties entirely, and makes iterating over the data more simple and clear.

// levelData.ts
import levelJSON from "./levelJSON.json";

interface ILevelJSON {
  [key: string]: Level;
}

interface Level {
  tiles: Tiles;
}

export type UnpassableType = "rock" | "tree";

type UnpassableTile = {
  type: UnpassableType;
  position: number[];
};

interface Tiles {
  unpassable_tiles: UnpassableTile[];
}

export default levelJSON as ILevelJSON;

To properly match the new interface, you'd need to modify levelJSON.json as well. But note that it's a lot cleaner and you'd don't need to define empty arrays for rocks or trees in level_2, those are simply absent:

{
  "level_1": {
    "tiles": {
      "unpassable_tiles": [
        { "type": "rock", "position": [0, 0] },
        { "type": "rock", "position": [2, 0] },
        { "type": "tree", "position": [2, 2] }
      ]
    }
  },
  "level_2": {
    "tiles": {
      "unpassable_tiles": []
    }
  }
}

Now you can very easily map over your impassable tiles, their types, and associated position data, all while retaining full type inference and safety. And it looks a lot more clear and understandable IMO.

// App.tsx
const UnpassableTileComponents = React.useMemo(() => {
  return levelData[`level_1`].tiles.unpassable_tiles.map(
    ({ type, position: [leftPos, topPos] }) => (
      <UnpassableTile
        key={`level_1_${type}_${leftPos}_${topPos}`}
        leftPos={leftPos}
        topPos={topPos}
        tileType={type}
      />
    )
  );
}, []);

https://codesandbox.io/s/goofy-snyder-u9x60?file=/src/App.tsx


You can further extend this philosophy to how you structure your Levels and their interfaces. Why not have levelJSON be an array of Level objects, each with a name and set of tiles?

interface Tiles {
  unpassable_tiles: UnpassableTile[];
}

interface Level {
  name: string;
  tiles: Tiles;
}

export type UnpassableType = "rock" | "tree";

type UnpassableTile = {
  type: UnpassableType;
  position: number[];
};

Your corresponding data would look a lot cleaner:

[
  {
    "name": "level_1",
    "tiles": {
      "unpassable_tiles": [
        { "type": "rock", "position": [0, 0] },
        { "type": "rock", "position": [2, 0] },
        { "type": "tree", "position": [2, 2] }
      ]
    }
  },
  {
    "name": "level_2",
    "tiles": {
      "unpassable_tiles": []
    }
  }
]

And iterating over it would become even more clear:

const level = levelData[0];

const UnpassableTileComponents = React.useMemo(() => {
  return level.tiles.unpassable_tiles.map(
    ({ type, position: [leftPos, topPos] }) => (
      <UnpassableTile
        key={`${level.name}_${type}_${leftPos}_${topPos}`}
        leftPos={leftPos}
        topPos={topPos}
        tileType={type}
      />
    )
  );
}, [level]);

https://codesandbox.io/s/hopeful-grass-dnohi?file=/src/App.tsx

发布评论

评论列表(0)

  1. 暂无评论