5 ms·
At Notion, we re-type Object.keys and Object.entries for the situations where you want more specific key types. For example, iterating over a `Record<ThingyIdTy
by jitl 3y ago
At Notion, we re-type Object.keys and Object.entries for the situations where you want more specific key types. For example, iterating over a `Record<ThingyIdType, ThingyUpdate>` while preserving the well-typed ID key, or iterating over a `SchemaDefinition<Thingy>` which is known to be statically declared as an exact object.
/**
* Like Object.keys, but unsound in exchange for more convenience.
*
* Casts the result of Object.keys to the known keys of an object type,
* even though JavaScript objects may contain additional keys.
*
* Only use this function when you know/control the provenance of the object
* you're iterating, and can verify it contains exactly the keys declared
* to the type system.
*
* Example:
* ```
* const o = {x: "ok", y: 10}
* o["z"] = "UNTRACKED_KEY"
* const safeKeys = Object.keys(o)
* const unsafeKeys = objectKeys(o)
* ```
* => const safeKeys: string[]
* => const unsafeKeys: ("x" | "y")[] // Missing "z"
*/
export const objectKeys = Object.keys as <T>(obj: T) => Array<keyof T>
/**
* The type of a single item in `Object.entries<T>(value: T)`.
*
* Example:
* ```
* interface T {x: string; y: number}
* type T2 = ObjectEntry<T>
* ```
* => type T2 = ["x", string] | ["y", number]
*/
export type ObjectEntry<T> = {
// Without Exclude<keyof T, undefined>, this type produces `ExpectedEntries | undefined`
// if T has any optional keys.
[K in Exclude<keyof T, undefined>]: [K, T[K]]
}[Exclude<keyof T, undefined>]
/**
* Like Object.entries, but returns a more specific type which can be less safe.
*
* Example:
* ```
* const o = {x: "ok", y: 10}
* const unsafeEntries = Object.entries(o)
* const safeEntries = objectEntries(o)
* ```
* => const unsafeEntries: [string, string | number][]
* => const safeEntries: ObjectEntry<{
* x: string;
* y: number;
* }>[]
*
* See `ObjectEntry` above.
*
* Note that Object.entries collapses all possible values into a single union
* while objectEntries results in a union of 2-tuples.
*/
export const objectEntries = Object.entries as <T>(
o: T
) => Array<ObjectEntry<T>>
- presentation 3y agoAt my company we just have a wrapper around the Object.keys function (and other similar functions) that provide a more specific type in case you are sure it won’t be a problem (which we generally are for objects we’ve created ourselves).
- rcfox 3y agoDoes it catch if you attempt to use a more complex type as a key? (In which case, you should use a Map instead of an object.) I wonder if you should be using `T extends string | number`
- bastawhiz 3y agoThese helpers just return existing information about an object, they don't wrap the object passed in. The generic gets inferred from the argument. It's not possible to pass in an object where 'keyof T' is anything other than string, number, and/or literal strings or numbers.
- hn_throwaway_99 3y agoI like this but I think it's generally preferable to have something like "unsafe" in the name, e.g. objectKeysUnsafe. Reason being it's very easy for a new developer to come along and see "objectKeys" being used all over the place, and then assume it's safe to use everywhere without realizing the problem it could cause. Putting "unsafe" in the name (unsafe actually feels a little strong for me, maybe something like "lax" or something else would be better?) makes it a lot more likely that a new dev is at least going to look up the doc on the function to see why it's unsafe.
- jitl 3y agoI think the unsoundness hazard here is more scary in NPM library code where you can't trust your caller too much. In our application, we generally don't create objects with extraneous properties or use subtyping/inheritance. I did a quick survey of the 316 occurrences of `objectEntries` in our codebase and found very few (like 15-ish) that weren't obviously on a Record type like `Record<ThingyID, Thingy>` or a static, constant configuration object like `{ [T in Foo["type"]]: HandleFooVariant<T> }`.
- derefr 3y agoNotion would be a useful at-scale codebase for a case-study: - How often are you forced to interact with JS objects over ECMA6 Maps? - How often are you not forced to use JS objects over ECMA6 Maps, but are using them anyway?
- jitl 3y agoES6 maps don't work well with mapped types where we encode a type-level relationship between a key and a value depending on the key type: type BlockRenderers<Out> = { [key in keyof BlockValues]: ( args: BlockRenderArgs<Out, BlockValues[key]> ) => Out | undefined } or something like: type Table = 'block' /* | ... ~70 other table names */ type ID<T extends Table> = `${T}:${UUID}` type TableToRow = { block: BlockValue // ... ~70 other table schemas } type RowsById = { [T in Table as ID<T>]: TableToRow[T] } Maps are also annoying to persist compared Record<K, V> types which just work with JSON. The majority of our code is oriented around JSON-serializable discriminated union types. In my benchmarking, transcoding to/from Map<K, V> to Record<K, V> doesn't seem worth the effort. So, we Map for in-memory only data organization and some framework stuff but most engineers tend to interact much more with persisted data and thus Record types.
- geon 3y agoMapping/looping over Object.entries is great. But you really should only be doing that with Record<>, not some random object. A record is like a container, such as an array, than a object with properties that might have different types.