allejo
allejo

Reputation: 2203

Reuse keys of first argument in another argument with generics

I have been trying to google how to solve this problem but I'm having a hard time even coming up with what to search for, so any help in editing this question or linking me to duplicates would be very helpful.

The Use Case

I have a function that as a first argument takes a Record of "column names" as the keys and interface fields as the values. For example, a mapping of,

{
  "user_id": "userID",
  "email__": "email",
}

and an interface of,

interface ExpectedShape {
  userID: number;
  email: string;
}

Casting

For reasons beyond my control, the columns where my data is coming above are named user_id and email__, and will always be returned as strings. So I need to define how to cast or clean up incoming data when creating JS objects matching the ExpectedShape interface.

So in order to allow this, I have a configuration variable in my method that will take the same keys as the original mapping but have methods that will cast things accordingly. For example,

{
  "user_id": Number.parseInt,
  "email__": sanitizeEmail,
}

So when user_id comes in from the data source, it will call Number.parseInt so that when it the ExpectedShape object is built, it'll be a proper number.

What I want to do

Via TypeScript, I want to enforce that the keys of my "mapping" and my "casting operations" be the same.

Here is some broken code that does not work but shows what I'd envision it to look like.

interface ExpectedShape {
    userID: number;
    email: string;
}

interface FunctionOptions<K extends keyof any, S> {
    casting: Record<K, (value: string) => S[keyof S]>;
}

function myFunction<T extends {}>(
    values: Record<string, keyof T>,
    config: FunctionOptions<keyof values, T>, // <-- What can I put here?
): Partial<T> {
    return {};
}

function sanitizeEmail(_: string): string { return ""; }

const result = myFunction<ExpectedShape>(
    {
      "user_id": "userID",
      "email__": "email",
    },
    {
        // I want to restrict via TypeScript that the keys of `casting` must be
        // the same as the keys of the `values` argument. See how it's missing
        // an underscore?
        casting: {
            userid: Number.parseInt,
        }
    },
);

What I have tried...

I have tried a lot. The closest I've gotten it to work and compile successfully is this,

function myFunction<
    T extends {},
    M extends Record<string, keyof T> = Record<string, keyof T>,
    P extends keyof M = keyof M,
>(
    values: M,
    config: FunctionOptions<P, T>,
): Partial<T> {
    return {};
}

However. It does not enforce keys correctly and allows me to use whatever keys I want.

Upvotes: 0

Views: 557

Answers (1)

jcalz
jcalz

Reputation: 328312

I think in order for this to work either at runtime or at the type system, you need to pass in something like three arguments: the input object you'd like to process; an object that maps the keys of the input object to those of the output object; and an object that maps the values of the input object to those of the output object. Presumably the key mapper would just be an object whose values are just keys, while the value mapper would be an object whose values are functions.

Here's one possible implementation:

function mapKeysAndValues<I extends object,
  KM extends Record<keyof I, S>,
  VM extends { [K in keyof I]: (v: I[K]) => any },
  S extends PropertyKey
>(
  inputObject: I,
  keyMapping: KM,
  valMapping: VM
): { [K in keyof I as KM[K]]: ReturnType<VM[K]> } {

  const ret: any = {};
  (Object.keys(inputObject) as Array<keyof I>).forEach(k =>
    ret[keyMapping[k]] = valMapping[k](inputObject[k] as any)
  )
  return ret;
}

We have three important generic type parameters here: I corresponds to the input object, KM corresponds to the key mapping object, and VM corresponds to the value mapping object.

( The type parameter S doesn't do much except provide a hint that we'd like the values of the key mapping object to be interpreted by the compiler as literal values and not something useless like string. See microsoft/TypeScript#30680 for a feature request discussing this. You can mostly ignore S ).

The heavy lifting is in the return type of mapKeysAndValues(). We use key remapping in mapped types to replace each key K in the input with KM[K] in the output. And we use the ReturnType<T> utility type in the property value to grab the return type of each property K's function in VM and use it as the value type for the corresponding property of the output type.

The implementation uses a bunch of type assertions to prevent the compiler from complaining (it really won't understand key remapping or correlated functions), but at runtime you can see it mostly just walks through the input object keys and performs the transformation for each one. (It is technically possible that inputObject might have extra properties not known of the the compiler, so you might want to make this more safe by doing additional runtime checks like if (k in valMapping) and if (typeof valMapping[k] === "function"). But I'll ignore that possibility.


Let's test it out:

const ret: ExpectedShape = mapKeysAndValues(
  { "user_id": "123", "email__": "[email protected]" },
  { "user_id": "userID", "email__": "email" },
  { "user_id": Number.parseInt, "email__": sanitizeEmail }
)

console.log(ret);
/* {
  "userID": 123,
  "email": "[email protected]"
}  */

Looks good! The compiler understands that mapKeysAndValues() will produce a value of the ExpectedShape type, and when we log it to the console we see that the right thing happened at runtime too.

Hopefully you can use mapKeysAndValues() as a starting point for the transformation that you're trying to accomplish.

Playground link to code

Upvotes: 2

Related Questions