I’m trying to make my own static class that adds some extra typings to Object.entries
and Object.fromEntries
. I feel I’m halfway there but am running into issues when getting it to implicitly detect values passed into array helper methods like .map()
.
Below is my Typescript Utility Class for Type Safety:
type TransformEntry<Entry, Val> = Entry extends [infer K, infer _V] ? [K, Extract<Val, Val>] : never;
export class Obj {
// ---- Type Helpers for Object ----
static entries<T extends object>(obj: T): { [K in keyof T]: [K, T[K]] }[keyof T][] {
return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][];
}
static fromEntries<T extends ([string, unknown])[]>(entries: T) {
return Object.fromEntries(entries) as { [K in T[number][0]]: Extract<T[number], [K, unknown]>[1] };
}
// ---- Custom Methods ----
static updateEntryVal = <
Entry extends [string, unknown],
NewValue,
>([key]: Entry, val: NewValue) => [key, val] as TransformEntry<Entry, NewValue>;
}
- I partially have this working with
.map()
function with certain issues - I have created
Obj.updateEntryVal()
to better implicitly type the return value of.map()
function - I am looking for an implicit solution that just detects any return value passed in to
.map()
- I want to avoid having to make a custom interface somwhere else in the code since it should rely on being implicit
Below is the type test:
const fruit = {
fruitName: 'apple' as const,
description: 'A sweet red fruit',
weight: 175,
isFresh: true,
};
// ✅ Works fine without applying .map() function
const entries = Obj.entries(fruit);
const newObj = Obj.fromEntries(entries);
// Working ✅
const fruitName = newObj.fruitName; // "apple" as const
const description = newObj.description; // string
const weight = newObj.weight; // number
const isFresh = newObj.isFresh; // boolean
// ✅ Example 1 (Working) - Update entry with a new value as a number
const example1 = Obj.entries(fruit).map((entry) =>
Obj.updateEntryVal(entry, 123)
);
const result1 = Obj.fromEntries(example1);
const fruitName1 = result1.fruitName; // working: number
const fruitDescription1 = result1.description; // working: number
const fruitWeight1 = result1.weight; // working: number
const fruitIsFresh1 = result1.isFresh; // working: number
// ❎ Example 2 (Broken) - Retain original value
const example2 = Obj.entries(fruit).map((entry) =>
Obj.updateEntryVal(entry, entry[1])
);
const result2 = Obj.fromEntries(example2);
const fruitName2 = result2.fruitName;
// - desired: 'apple' as const
// - result: string | number | boolean
const fruitDescription2 = result2.description;
// - desired: string
// - result: string | number | boolean
const fruitWeight2 = result2.weight;
// - desired: number
// - result: string | number | boolean
const fruitIsFresh2 = result2.isFresh;
// - desired: boolean
// - result: string | number | boolean
// ❎ Example 3 (Broken) - Transform value with a function
const example3 = Obj.entries(fruit).map((entry) =>
Obj.updateEntryVal(entry, () => entry[1])
);
const result3 = Obj.fromEntries(example3);
const fruitName3 = result3.fruitName;
// - desired: () => 'apple' as const
// - result: () => string | number | boolean
const fruitDescription3 = result3.description;
// - desired: () => string
// - result: () => string | number | boolean
const fruitWeight3 = result3.weight;
// - desired: () => number
// - result: () => string | number | boolean
const fruitIsFresh3 = result3.isFresh;
// - desired: () => boolean
// - result: () => string | number | boolean
// ❎ Example 4 (Broken) - Wrap value in an object
const example4 = Obj.entries(fruit).map((entry) =>
Obj.updateEntryVal(entry, { test: entry[1] })
);
const result4 = Obj.fromEntries(example4);
const fruitName4 = result4.fruitName;
// - desired: { test: 'apple' as const }
// - result: { test: string | number | boolean }
const fruitDescription4 = result4.description;
// - desired: { test: string }
// - result: { test: string | number | boolean }
const fruitWeight4 = result4.weight;
// - desired: { test: number }
// - result: { test: string | number | boolean }
const fruitIsFresh4 = result4.isFresh;
// - desired: { test: boolean }
// - result: { test: string | number | boolean }
I think the issue is with <Val>
and being able to infer it. If <Val>
comes as a Union Type how do we infer/extract it to match infer K? Example2 also seems to be the same union as infer _V
// Note: The union matching to create a working Object.fromEntries() works if we incorrectly use infer _V.
// ------------------------------------------------------------
// Context:
// Entry: ["fruitName", "apple"] | ["description", string] | ["weight", number] | ["isFresh", boolean]
// infer K: "fruitName" | "description" | "weight" | "isFresh"
// infer _V: "apple" | string | number | boolean
// Val:
// - Example 1: 123 - number
// - Example 2: entry[1] - "apple" | string | number | boolean - Same union as infer _V but does not key match with infer K
// - Example 3: () => entry[1] - () => string | number | boolean
// - Example 4: { test: entry[1] } - { test: string | number | boolean }
type TransformEntry<Entry, Val> = Entry extends [infer K, infer _V] ? [K, Extract<Val, Val>] : never;
For additional reference this is my tsconfig.json environment
{
"compileOnSave": false,
"compilerOptions": {
"rootDir": ".",
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "es2022",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"strict": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"baseUrl": ".",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
},
"exclude": ["node_modules", "tmp"]
}
3