DEV Community

Discussion on: The Record Utility Type in TypeScript

Collapse
 
peerreynders profile image
peerreynders • Edited
type User = {
  id: number;
  firstname: string;
  lastname: string;
  age?: number;
};

// ES2019
const users: Record<number, User> = Object.fromEntries(
  [
    { id: 1, firstname: 'Chris', lastname: 'Bongers' },
    { id: 2, firstname: 'Yaatree', lastname: 'Bongers', age: 2 },
  ].map((user) => [user.id, user])
);

for (const key of Object.getOwnPropertyNames(users))
  console.log(`Key: ${key} of type ${typeof key}`);

// output:
// "Key: 1 of type string"
// "Key: 2 of type string"
Enter fullscreen mode Exit fullscreen mode

Nice going, TypeScript! 🤦

MDN: Objects and properties:

Please note that all keys in the square bracket notation are converted to string unless they're Symbols, since JavaScript object property names (keys) can only be strings or Symbols

type User = {
  id: number;
  firstname: string;
  lastname: string;
  age?: number;
};

// correctly typed as
//
// const users: {
//   [k: string]: User;
// }
const users = Object.fromEntries<User>(
  [
    { id: 1, firstname: 'Chris', lastname: 'Bongers' },
    { id: 2, firstname: 'Yaatree', lastname: 'Bongers', age: 2 },
  ].map((user) => [user.id, user])
);

for (const key of Object.getOwnPropertyNames(users))
  console.log(`Key: ${key} of type ${typeof key}`);

// output:
// "Key: 1 of type string"
// "Key: 2 of type string"
Enter fullscreen mode Exit fullscreen mode
type User = {
  id: number;
  firstname: string;
  lastname: string;
  age?: number;
};

type UsersById = Map<number, User>;

const users: UsersById = new Map(
  [
    { id: 1, firstname: 'Chris', lastname: 'Bongers' },
    { id: 2, firstname: 'Yaatree', lastname: 'Bongers', age: 2 },
  ].map((user) => [user.id, user])
);

for (const key of users.keys())
  console.log(`Key: ${key} of type ${typeof key}`);

// output:
// "Key: 1 of type number"
// "Key: 2 of type number"
Enter fullscreen mode Exit fullscreen mode
Collapse
 
dailydevtips1 profile image
Chris Bongers

Ah thanks for adding this Peer,
valid point indeed 🙌

Collapse
 
peerreynders profile image
peerreynders • Edited

This is just one of those examples to demonstrate that just because you're using TypeScript doesn't mean you can check your brain at the door. If you think TypeScript has your back, think again; you still need to have a good grasp of how JavaScript works.

To be fair TypeScript to some degree is just being consistent in it's own way but sometimes (ironically) without taking JavaScript's idiosyncrasies into account.

Take the following variation:

type User = {
  id: number,
  firstname: string,
  lastname: string,
  age?: number,
};

const fee = Symbol('fee');
const fi = Symbol('fi');

// Why is there no TS error here?
const users: Record<number, User> = Object.fromEntries([
  [fee, { id: 1, firstname: 'Chris', lastname: 'Bongers' }],
  [fi, { id: 2, firstname: 'Yaatree', lastname: 'Bongers', age: 2 }],
]);

for (const key of Object.getOwnPropertySymbols(users))
  console.log(`Key: ${String(key)} of type ${typeof key}`);

console.log(users[fee]); // Access works but TS Error:
console.log(users[fi]); // "Element implicitly has an 'any' type because index expression is not of type 'number'."

// output:
// "Key: Symbol(fee) of type symbol""
// "Key: 2 of type string"
//
// {
//   "id": 1,
//  "firstname": "Chris",
//  "lastname": "Bongers"
// }
// {
//  "id": 2,
//  "firstname": "Yaatree",
//  "lastname": "Bongers",
//  "age": 2
// }
Enter fullscreen mode Exit fullscreen mode

For some reason TypeScript doesn't catch the problem around users: Record<number>.

The initial problem happens around ObjectFromEntries<T>. The definition

fromEntries<T = any>(entries: Iterable<readonly [PropertyKey, T]>): { [k: string]: T };
Enter fullscreen mode Exit fullscreen mode

That definition covers the majority case but we actually have { [k: symbol]: User } here.

Then for some reason there is no complaint that we are binding a supposed { [k: string]: User } to a Record<number, User>:

es5.d.ts

type Record<K extends keyof any, T> = {
    [P in K]: T;
};
Enter fullscreen mode Exit fullscreen mode

i.e. it has no problem accepting a { [k: string]: User } for a { [k: number]: User }?

It's only once we get down to users[fee] that the complaining starts: "This type's properties should only be accessed with a number".

So my takeaway:

  • Use Records with string, symbol or a union of string literal types for Keys.
  • While any type that can be coerced to string, like number, will work, TypeScript will enforce the usage of the declared Keys type - forcing type coercion for any access at runtime. So the static typing "works" but really doesn't jive with what is happening at runtime. My personal preference is to enforce the actual types that are present at runtime.
  • If a Keys type is needed that isn't a string or symbol use a Map instead. However unlike Record<Keys,Type> Map won't require all the members of a Keys union.

Partial<Type> can be a companion utility to a Record<Key,Type> that uses a union for Keys:

const KEYS = ['id', 'firstname', 'lastname'] as const;
type Keys = typeof KEYS[number];

type UserDataEntries = [Keys, string][];
type User = Record<Keys, string>;

const userData: [string, string][] = [
  ['id', '1'],
  ['firstname', 'Chris'],
  ['lastname', 'Bongers'],
];

if (!isUserDataEntries(userData)) throw new Error('Invalid user data entries');
// Is now
// const userData: UserDataEntries

// const user: Partial<User>
const user = userData.reduce<Partial<User>>((temp, [key, value]) => {
  temp[key] = value;
  return temp;
}, {});

if (!isUser(user)) throw new Error('Incomplete user data');
// Is now
// const user: User
console.log('Success:', user);

// type predicates
// https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates
//
function isUserDataEntries(data: [string, string][]): data is UserDataEntries {
  const validKeys: readonly string[] = KEYS; // deliberately widen type
  return data.every(([key, _value]) => validKeys.includes(key));
}

function isUser(partial: Partial<User>): partial is User {
  // ES2022
  return KEYS.every((key) => Object.hasOwn(partial, key));
}
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
dailydevtips1 profile image
Chris Bongers

100% agree with you, just because we are Typescript we can't just leave things to it.
We should still validate everything.

Just one small question about your initial comment,
just because it handles the numbers as strings, is that really a concern though?

In most cases it's not really used anyway (just wondering why we should even care about that)

Thread Thread
 
peerreynders profile image
peerreynders • Edited

just because it handles the numbers as strings, is that really a concern though?

On the surface no, because TypeScript tries to enforce consistent access with the declared type.

But it still leaves a JavaScript type coercion footgun in place:

const record: Record<string | number, string> = {
  0: 'zero',
  1: 'one',
  2: 'two',
};

const oldOne = 1;
const newOne = '1';

console.assert(record[oldOne] === 'one');
record[newOne] = 'newOne';
// Oops
console.assert(record[oldOne] === 'newOne');

// Meanwhile TS flags this as an error
// "This condition will always return 'false' since the types 'number' and 'string' have no overlap"
// Actually it returns `true` due to type coercion; TS just doesn't like it...
// console.assert(oldOne == newOne);

// ...because
console.assert(typeof oldOne !== typeof newOne);

// At least this works (ES2022)
console.assert(Object.hasOwn(record, 2));
console.assert(Object.hasOwn(record, (2).toString()));
Enter fullscreen mode Exit fullscreen mode

Similarly

const values = ['zero', 'one', 'two'];

const oldOne = 1;
const newOne = '1';
const anotherOne = '01';

console.assert(values[oldOne] === 'one');

values[newOne] = 'newOne';
console.assert(values[oldOne] === 'newOne');

console.assert(typeof oldOne !== typeof newOne);

console.assert(Object.hasOwn(values, oldOne));
console.assert(Object.hasOwn(values, oldOne.toString()));
console.assert(Object.hasOwn(values, newOne));
console.assert(!Object.hasOwn(values, anotherOne));
Enter fullscreen mode Exit fullscreen mode

So TypeScript being typed had an opportunity to clean things up at least for objects to restrict property keys to strings and symbols but existing JavaScript code bases sometimes use numbers as property keys (for convenience) and not supporting that could have hurt adoption.