Effect-TS / effect

An ecosystem of tools for building production-grade applications in TypeScript.
https://effect.website
MIT License
7.18k stars 229 forks source link

Add renameKey and renameKeys functions to the Record module #3653

Open sromexs opened 1 week ago

sromexs commented 1 week ago

What is the problem this feature would solve?

Currently, the Record module lacks a built-in, type-safe method for renaming keys in an object while preserving full type information in TypeScript. Renaming object keys is a common task in data transformation, but existing approaches often lead to loss of type information or require cumbersome and error-prone workarounds. Developers need a reliable way to rename keys without sacrificing type safety or resorting to manual type definitions.

What is the feature you are proposing to solve the problem?

I propose adding two new utility functions to the Record module:

  1. renameKey: A function that renames a single key in an object.

    • Signature:
      function renameKey<
      T extends Record<string | symbol, unknown>,
      K extends keyof T,
      K2 extends string,
      >(input: T, key: K, newKey: K2): { [P in Exclude<keyof T, K> | K2]: T[P extends K2 ? K : P] };
    • Usage:
      const source = { foo: 1, bar: "baz" } as const;
      const result = renameKey(source, "foo", "yolo");
      // Resulting type:
      // { readonly yolo: 1; readonly bar: "baz"; }
  2. renameKeys: A function that renames multiple keys in an object based on a mapping.

    • Signature:
      function renameKeys<
      T extends Record<string | symbol, unknown>,
      M extends Record<string, string>,
      >(input: T, map: M): {
      };
    • Usage:
      const source = { foo: 1, bar: "baz" } as const;
      const mapping = { foo: "yolo", bar: "qux" } as const;
      const result = renameKeys(source, mapping);
      // Resulting type:
      // { readonly yolo: 1; readonly qux: "baz"; }

These functions solve the problem by:

What alternatives have you considered?

Several alternatives were considered but found lacking:

  1. Manual Key Renaming with Type Casting:

    • Drawbacks:
      • Verbose and error-prone.
      • Requires manual updates to types, increasing maintenance overhead.
      • Risk of losing type information or introducing type errors.
  2. Using Existing Utility Functions or Libraries:

    • Drawbacks:
      • Third-party libraries may not handle type preservation adequately.
      • Introduces additional dependencies to the project.
      • May not align with the project's existing patterns or standards.
  3. Custom Helper Functions in Individual Projects:

    • Drawbacks:
      • Leads to code duplication across different projects.
      • Inconsistent implementations can cause confusion and bugs.
      • Lacks the optimization and testing that a standardized utility would have.
  4. Extending Types with Mapped Types Manually:

    • Drawbacks:
      • Can become complex quickly, especially with deeply nested objects.
      • Increases cognitive load for developers.
      • Not practical for dynamic key renaming based on runtime data.

These alternatives either compromise on type safety, increase complexity, or don't provide a reusable and maintainable solution. By adding renameKey and renameKeys to the Record module, we offer a robust, type-safe, and developer-friendly solution to a common problem.

fnimick commented 1 week ago

EDIT: cleaned up the return type signatures

export function renameKeys<
  T extends Record<string | symbol, unknown>,
  M extends Record<string, string>,
>(input: T, map: M): { [Key in keyof T as Key extends keyof M ? M[Key] : Key]: T[Key] } {
  return Record.mapKeys(input, (key) => (key in map ? map[key as keyof M] : key)) as any;
}

export function renameKey<
  T extends Record<string | symbol, unknown>,
  K extends keyof T,
  K2 extends string,
>(input: T, key: K, newKey: K2): { [Key in keyof T as Key extends K ? K2 : Key]: T[Key] } {
  return renameKeys(input, { [key]: newKey } as Record<K, K2>) as any;
}
fnimick commented 1 week ago

Hm, the types here aren't as nice as I'd like when there is a collision in renaming.

As an example:

  test("name overrides", () => {
    const obj = { a: 1, b: 2 } as const;
    const res = renameKeys(obj, { a: "b" } as const);
    expect(res).toEqual({ b: 2 }); // type is inferred as { b: 1 | 2 }
  });
  test("name overrides 2", () => {
    const obj = { a: 1, b: 2 } as const;
    const res = renameKeys(obj, { b: "a" } as const);
    expect(res).toEqual({ a: 2 }); // type is inferred as { a: 1 | 2 }
  });

Unfortunately, there's no way around this as the types are evaluated at compile time, but you could easily reorder the entries in the object at runtime so there's a different evaluation order in the Record.mapKeys call and therefore get a different runtime value result.