0

I have a function that takes in a rename function and an object, and renames the object's keys based on the rename function.

export default function mapKeys<T extends { [s: string]: T } | ArrayLike<T>>(
  renameFunction: (key: string) => string,
  object: T,
): T {
  return Object.fromEntries(
    Object.entries(object).map(([key, value]) => [renameFunction(key), value]),
  );
}

You can use it like this:

mapKeys(key => key.toUpperCase(), { a: 1, b: 2 });
// => { A: 1, B: 2 }

The problem is, that TypeScript complains about both, the function definition:

Type '{ [k: string]: T; }' is not assignable to type 'T'.
  '{ [k: string]: T; }' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{ [s: string]: T; } | ArrayLike<T>'.

as well as the errors for the application:

Type 'number' is not assignable to type '{ a: number; b: number; }'.

and

Type 'number' is not assignable to type '{ a: number; b: number; }'.

Is there a way to type this function, so these errors compile? Ideally, TypeScript would also be aware of the output shape of the function, e.g. if the renameFunction is something like:

const rename = (a: string) => `${a}Error`;

and the object is:

{ email: 'foo' }

that then TypeScript knows, the key in the resulting object is emailError.

I tried writing the function more generic, like this:

export default function mapKeys(
  renameFunction: (key: string) => string,
  object: object,
): object {
  return Object.fromEntries(
    Object.entries(object).map(([key, value]) => [renameFunction(key), value]),
  );
}

This compiles, but TypeScript loses the information about the object keys.

If I have an object like { name: 'foo' } and a rename function like rename: (a: string) => a + 'Foo', TypeScript doesn't know the returned object is of shape { nameFoo: string }.

J. Hesters
  • 13,117
  • 31
  • 133
  • 249
  • This is essentially impossible as far as I know. Even if you manage to convince the compiler that some callback is of type `(x: string) => \`${T}Foo`\`, the call signature of `mapKeys()` would need to involve *higher kinded types* as requested in [ms/TS#1213](https://github.com/microsoft/TypeScript/issues/1213)... and those are not part of the language. It's very similar to [this problem](https://stackoverflow.com/a/66091730/2887218). – jcalz Dec 12 '22 at 22:24
  • The closest you can get seems to be to try to simulate/emulate higher kinded types, which involves what looks like a registry, so you basically have to merge your rename function signature into an interface before you can use it, as shown [here](https://tsplay.dev/wE483W)). It's almost certainly not worth it. – jcalz Dec 12 '22 at 22:52
  • Does that fully address your question? If so I can write up an answer explaining more fully. If not, what am I missing? – jcalz Dec 12 '22 at 22:53

1 Answers1

0

What you need for this case is to define two things, for every renameFunction the actual (run-time) implementation and type-level implementation. Of course, you will be limited by the type system, so you can't really denote any arbitrary runtime mapping in the type-system.

So, the renameFunction will have this type:

type RenameFunction<T extends Renamer> = (x: string) => string

where the T will be type-level function and the right side is the runtime implementation.

Renamer, a part of type-level function implementation will be implemented like this:

interface Renamer {
    A: string
    type: string
}

where A will represent the input type and type the output type.

Then, this will be the shape of the actual type-level function:

interface UppercaseRenamer extends Renamer {
    type: Uppercase<this["A"]>
}

The type will be result of A field with some type-level function "called" on it.

This is the type-level function declaration, you also need the other side, the point where you call this type-level function.

type HigherKind<F extends Renamer, A> = (F & { A: A })["type"]

Here, you merge F which represents the type-level function and {A: A} which represents the argument and extract from the "type" field which represents the type-level result. In this way, you emulate higher-kinded types in typescript.

Then, you can use the encoding in the actual function:

type Result<T, U extends Renamer> = 
{
    [K in keyof T as HigherKind<U, K>] : T[K]
}

export default function mapKeys<T extends object, U extends Renamer>(
  renameFunction: RenameFunction<U>,
  object: T,
): Result<T, U> {
  return Object.fromEntries(
    Object.entries(object).map(([key, value]) => [renameFunction(key), value]),
  ) as Result<T, U>
}

The last part is the runtime implementation, you need the indicate the type-level function in the signature.

const uppercase: RenameFunction<UppercaseRenamer> = (x: string) => x.toUpperCase()

For his calling:

const result = mapKeys(uppercase, {a: "ttt", c: "xxx"})

result will have type:

{
  A: string,
  C: string
}

You can also define other function like this:

interface RepeatRenamer extends Renamer {
    type: `${this["A"]}${this["A"]}`
}
const repeat: RenameFunction<RepeatRenamer> = (x: string) => `${x}${x}`
const result2 = mapKeys(repeat, {a: "b", c: "d"})

Then, the result type will be

{
  aa: string,
  cc: string
}
ryskajakub
  • 6,351
  • 8
  • 45
  • 75