0

I'm having trouble writing the types for the following getStructToResponse function using typescript:

const result = getStructToResponse({
  name: { toResponse: (x: string) => x.length },
  age: { toResponse: (x: number) => [x] },
}).toResponse({
  name: "ok",
  age: "error", // compiler should complain because `toResponse` expects a `number`,
  whatever: "nope" // compiler should complain because this key is missing
})

Where the inferred returned type of toResponse should be:

type Result = {
  name: number;
  age: number[];
}

Here is the implementation of getStructResponse without types:

const getStructToResponse = (objectOfFunctions) => ({
  toResponse: (objectOfValues) => {
    var result = {}
    
    Object.keys(objectOfValues).forEach(key => {
      result[key] = objectOfFunctions[key].toResponse(objectOfValues[key])
    })

    return result
  }
})

Any suggestion? Is this even possible to fully type this correctly with TypeScript?

Thanks

ford04
  • 66,267
  • 20
  • 199
  • 171
Alerosa
  • 568
  • 1
  • 5
  • 14

2 Answers2

3

Here's my recommendation for how to type getStructToResponse() using mapped types as well as the utility types Parameters and ReturnType to extract the parameters and return types of a function type, respectively:

const getStructToResponse = <T extends { [K in keyof T]: { toResponse: (arg: any) => any } }>(
  objectOfFunctions: T
) => ({
  toResponse: (objectOfValues: { [K in keyof T]: Parameters<T[K]['toResponse']>[0] }) => {
    var result = {} as { [K in keyof T]: ReturnType<T[K]['toResponse']> }

    (Object.keys(objectOfFunctions) as Array<keyof T>).forEach(<K extends keyof T>(key: K) => {
      result[key] = objectOfFunctions[key].toResponse(objectOfValues[key])
    })

    return result;
  }
})

This function is generic in the type T of objectOfFunctions, which is constrained to be an object whose property values have a toResponse property that take a single argument. The function that it returns accepts an object with the same keys as T, but where each property is assignable to the first parameter in the toResponse function in the corresponding property of T. And it returns an object with again, the same keys, but whose property values are assignable to the return types of the toResponse functions in T.

A few things to note:

  • I changed Object.keys(objectOfValues) to Object.keys(objectOfFunctions). This is a bit safer because it isn't always possible to prevent extra keys in the objectOfValues function argument. Excess property checking only happens in particular circumstances, so it's best to make sure your code handles extra properties safely. Since the type T is inferred from objectOfFunctions, it's less likely to go wrong if you enumerate its keys instead of those from objectOfValues. If you really want to be safe you might need to do a little more runtime checking to make sure that an extra key doesn't sneak into either of your objects.

  • The result value starts off as an empty object whose type is not a suitable return type. So I have used as to assert that it will be of the desired return type by the time it's returned.

  • Object.keys(obj) returns string[] and not (keyof typeof obj)[]. This is the desired behavior (see this question for more info). I am using another assertion as Array<keyof T> to say that we expect in practice that Object.keys(objectOfFunctions) will only contain the keys from type T. This is technically not true; the type T will have the keys from objectOfFunctions that the compiler knows about, but it might not be all the keys. Again, as before, if you really want to be safe, you should do more runtime checking.

  • I've given the callback to forEach() a generic type signature in the particular subtype K of keyof T that key is assignable to. This makes the body of the callback type-check, because the compiler does understand that you are writing the right value type; that both sides of the = are of type ReturnType<T[K]['toResponse']>.


Let's see if it works:

const structToResponse = getStructToResponse({
  name: { toResponse: (x: string) => x.length },
  age: { toResponse: (x: number) => [x] },
});

const result = structToResponse.toResponse({
  name: "ok",
  age: "error", // error, string is not a number
})

const result2 = structToResponse.toResponse({
  name: "ok",
  age: 123,
  whatever: "nope" // error, unexpected property "whatever" (excess prop)
})

These are the errors you're looking for. As I said before, excess property checking (catching whatever above) doesn't always happen. For example, if you make objectOfValues ahead of time and then pass it in (instead of having it be an object literal), there will be no excess property warning:

const o = { name: "ok", age: 123, whatever: "nope" };
structToResponse.toResponse(o);  // <-- not an error now

That's the main reason I changed from Object.keys(objectOfValues) to Object.keys(object.ofFunctions). So the prior line doesn't give a

console.log(o); // { "name": "ok", "age": 123, "whatever": "nope" } 

Finally, let's make sure the output is correct at compile time and runtime in the good case:

const goodResult = structToResponse.toResponse({
  name: "ok",
  age: 123
});
/* const goodResult: {
    name: number;
    age: number[];
} */
console.log(goodResult); // {name: 2, age: [123]}

Looks like it does.


Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 1
    goddammit @jcalz you're always beating me! I'm almost done typing my response. – Linda Paiste Oct 26 '20 at 20:24
  • 1
    Oops, yeah, that happens to me too all the time (waves to @TitianCernicovaDragomir). In such cases, if my answer is similar to the one that gets in there before me, I'll usually just delete (or not post) my answer, and possibly make a suggestion in a comment for anything extra that I think is still noteworthy (usually links to documentation or relevant github issues). If they are different then I usually just post mine as well. Hopefully that's reasonable. I don't mean to step on any toes! – jcalz Oct 26 '20 at 20:33
  • 1
    I just submitted it anyways because I didn't want my work to go to waste. After reviewing your answer it's basically the same thing except that I like to define utility types outside of the function (I think it's more readable that way) and you put yours inline. Also you figured out how to deal with the excess properties. I was getting there but then I saw that you posted so I just stopped lol. – Linda Paiste Oct 26 '20 at 20:36
  • 1
    I wonder if there's ever a question where we have radically different approaches, or if there's always a "right answer" for everything. I mean here you approached it with `Parameters` and `ReturnType` whereas I used `infer` but it's all the same. – Linda Paiste Oct 26 '20 at 20:39
  • 1
    This is great, thanks a lot to both of you! I was using mapped types as well for the keys but I couldn't figure out how to "connect" the object values. The excess property check is not important for my implementation so this solution is perfect. The detailed explanation is super useful, thanks again. – Alerosa Oct 27 '20 at 08:05
3

The getStructToResponse function starts with an object of map functions, so the type of that object should be the generic and we'll work backwards from that to the input and output object types.

We define a general Mapper<In, Out> as an object with a property toResponse that maps a value from the In type to the Out type:

type Mapper<In, Out> = { 
    toResponse: (value: In) => Out
}

Given an object whose values are Mapper, we can work backwards to the input and output of the object that it maps:

type InputsFromMap<M> = {
    [K in keyof M]: M[K] extends Mapper<infer In, any> ? In : never;
}

type OutputsFromMap<M> = {
    [K in keyof M]: M[K] extends Mapper<any, infer Out> ? Out : never;
}

Our function is generic depending on a MapObj, which we say extends Record<keyof any, Mapper<any, any> so we know that every property is a Mapper.

It returns an object with a toResponse function, so it returns a Mapper. We use those inferred types to determine that we are returning Mapper<InputsFromMap<MapObj>, OutputsFromMap<MapObj>>.

We don't need to type objectOfValues because it is already known to be InputsFromMap<MapObj> from our function return. We do need to set an initial type for var result = {} as OutputsFromMap<MapObj> or else we won't be able to assign values to it because the key type won't be known. We also need to specify inside the forEach that the type for the key is keyof MapObj and not just string.

const getStructToResponse = <MapObj extends Record<keyof any, Mapper<any, any>>>(
  objectOfFunctions: MapObj
): Mapper<InputsFromMap<MapObj>, OutputsFromMap<MapObj>> => ({
  toResponse: (objectOfValues) => {
    var result = {} as OutputsFromMap<MapObj>

    Object.keys(objectOfFunctions).forEach((key: keyof MapObj) => {
      result[key] = objectOfFunctions[key].toResponse(objectOfValues[key]);
    })

    return result
  }
})

This gives us the errors that we want on invalid values. It doesn't give us error on excess properties, yet, but I'm just going to give up and hit "Post" because while I was typing this @jcalz came along and answered .

Edit: changed Object.keys(objectOfValues) to Object.keys(objectOfFunctions) as suggested by @jcalz. You still won't get errors on excess properties, but they'll be dropped from the result and won't cause errors.

Typescript Playground Link.

Linda Paiste
  • 38,446
  • 6
  • 64
  • 102
  • 1
    Thanks for your effort, having a slightly different approach to the same problem is incredibly useful from a learning perspective. – Alerosa Oct 27 '20 at 08:09