typeUser={id:number;firstname:string;lastname:string;age?:number;};// ES2019constusers: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(constkeyofObject.getOwnPropertyNames(users))console.log(`Key: ${key} of type ${typeofkey}`);// output:// "Key: 1 of type string"// "Key: 2 of type string"
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
typeUser={id:number;firstname:string;lastname:string;age?:number;};// correctly typed as//// const users: {// [k: string]: User;// }constusers=Object.fromEntries<User>([{id:1,firstname:'Chris',lastname:'Bongers'},{id:2,firstname:'Yaatree',lastname:'Bongers',age:2},].map((user)=>[user.id,user]));for(constkeyofObject.getOwnPropertyNames(users))console.log(`Key: ${key} of type ${typeofkey}`);// output:// "Key: 1 of type string"// "Key: 2 of type string"
typeUser={id:number;firstname:string;lastname:string;age?:number;};typeUsersById=Map<number,User>;constusers:UsersById=newMap([{id:1,firstname:'Chris',lastname:'Bongers'},{id:2,firstname:'Yaatree',lastname:'Bongers',age:2},].map((user)=>[user.id,user]));for(constkeyofusers.keys())console.log(`Key: ${key} of type ${typeofkey}`);// output:// "Key: 1 of type number"// "Key: 2 of type number"
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:
typeUser={id:number,firstname:string,lastname:string,age?:number,};constfee=Symbol('fee');constfi=Symbol('fi');// Why is there no TS error here?constusers:Record<number,User>=Object.fromEntries([[fee,{id:1,firstname:'Chris',lastname:'Bongers'}],[fi,{id:2,firstname:'Yaatree',lastname:'Bongers',age:2}],]);for(constkeyofObject.getOwnPropertySymbols(users))console.log(`Key: ${String(key)} of type ${typeofkey}`);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// }
For some reason TypeScript doesn't catch the problem around users: Record<number>.
The initial problem happens around ObjectFromEntries<T>. The definition
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:
constKEYS=['id','firstname','lastname']asconst;typeKeys=typeofKEYS[number];typeUserDataEntries=[Keys,string][];typeUser=Record<Keys,string>;constuserData:[string,string][]=[['id','1'],['firstname','Chris'],['lastname','Bongers'],];if(!isUserDataEntries(userData))thrownewError('Invalid user data entries');// Is now// const userData: UserDataEntries// const user: Partial<User>constuser=userData.reduce<Partial<User>>((temp,[key,value])=>{temp[key]=value;returntemp;},{});if(!isUser(user))thrownewError('Incomplete user data');// Is now// const user: Userconsole.log('Success:',user);// type predicates// https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates//functionisUserDataEntries(data:[string,string][]):dataisUserDataEntries{constvalidKeys:readonlystring[]=KEYS;// deliberately widen typereturndata.every(([key,_value])=>validKeys.includes(key));}functionisUser(partial:Partial<User>):partialisUser{// ES2022returnKEYS.every((key)=>Object.hasOwn(partial,key));}
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:
constrecord:Record<string|number,string>={0:'zero',1:'one',2:'two',};constoldOne=1;constnewOne='1';console.assert(record[oldOne]==='one');record[newOne]='newOne';// Oopsconsole.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);// ...becauseconsole.assert(typeofoldOne!==typeofnewOne);// At least this works (ES2022)console.assert(Object.hasOwn(record,2));console.assert(Object.hasOwn(record,(2).toString()));
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.
Nice going, TypeScript! 🤦
MDN: Objects and properties:
Ah thanks for adding this Peer,
valid point indeed 🙌
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:
For some reason TypeScript doesn't catch the problem around
users: Record<number>
.The initial problem happens around
ObjectFromEntries<T>
. The definitionThat 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 aRecord<number, User>
:es5.d.ts
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 anumber
".So my takeaway:
string
,symbol
or a union of string literal types forKeys
.string
, likenumber
, will work, TypeScript will enforce the usage of the declaredKeys
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.Keys
type is needed that isn't astring
orsymbol
use a Map instead. However unlikeRecord<Keys,Type>
Map
won't require all the members of aKeys
union.Partial<Type>
can be a companion utility to aRecord<Key,Type>
that uses a union forKeys
: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)
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:
Similarly
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
number
s as property keys (for convenience) and not supporting that could have hurt adoption.