2

I'm trying to write a function main that takes an object opts, and returns another object. opts has two optional properties a and b.

If a is present, the returned object should have a property aResult. If b is present, the returned object should have a property bResult. If both a and b are present on opts, then both aResult and bResult should be present on the returned object.

I can achieve this with overloads:

interface usesA {
    a: string;
}

interface usesB {
    b: string;
}

interface aReturnType {
    aResult: string;
}

interface bReturnType {
    bResult: string;
}

function main(opts: usesA): aReturnType;
function main(opts: usesB): bReturnType
function main(opts: usesA & usesB): aReturnType & bReturnType; 
// implementation omitted

But this gets quite long-winded if I want to add more optional properties: c which maps onto cResult, d which maps onto dResult etc.

Is there a more succinct way of doing this, e.g. using a generic function?

dipea
  • 392
  • 4
  • 17
  • have you tried, `main(opts: T): T[K] `? – mr.vea Apr 27 '20 at 18:32
  • I might misunderstand, but I don't think that's quite what I'm looking for. If I do: `declare function main(opts: T): T[K];` `const example: usesA = { a: 'foo' };` `const result = main(example);` Then the type of result is shown as `string`, where it should be `{ aResult: string }` – dipea Apr 27 '20 at 18:45
  • I am sorry, I misinterpret the example. So you want to append `Result` to the key of the previous object. I could never make the type to have the ability to append strings to keys. The best I could come up with is `main(opts: T): { [K in keyof T]: T[K] }`. The extended type arbitrary, so anything could be use instead of `any`; – mr.vea Apr 27 '20 at 20:57

1 Answers1

2

Given an interface like:

// Mapping of argument types to return types
interface MyMapping {
  a: { aResult: string }
  b: { bResult: string }
}

You can definitely do some magic.

First you'll want a type for your argument. This will be each key from from the mapping optionally included, with a value of string.

// Type of arguments. Each key in the mapping has a string value.
type UsesArgs<T> = { [K in keyof T]?: string }

Getting the return type is a bit trickier. You basically want to map over each property of the argument, and then lookup that type in the mapping. Note that when iterating we need to enforce that keys are of type keyof Args & keyof Mapping since we want to be sure that each key is present in both types.

// Type of each possible return value, as a union of all types.
type ReturnValueTypeUnion<Args, Mapping> = {
  // for each key Args and Mapping have in common, get the type from mapping
  [K in keyof Args & keyof Mapping]: Mapping[K]

// Get values from mapping as a union
}[keyof Args & keyof Mapping]

The problem is that produces a union { a: string } | { b : string }, but what we actually want is an intersection { a: string } & { b: string }. Luckily, this answer has a helper type for that.

// Convert a union to an intersection 
intersection-type
type UnionToIntersection<U> = (U extends any
? (k: U) => void
: never) extends (k: infer I) => void
  ? I
  : never

Now let's make a type to make put all that together:

// Type of the return values, as an intersection.
type ReturnValueType<Args, Mapping> = 
  UnionToIntersection<ReturnValueTypeUnion<Args, Mapping>>

And we can finally type the function:

declare function main<T extends UsesArgs<MyMapping>>(
  opts: T,
): ReturnValueType<T, MyMapping>

// Examples
const a = main({ a: 'abc' }) // { aResult: string }
const b = main({ b: 'def' }) // { bResult: string }
const aPlusB = main({ a: 'abc', b: 'def' }) // { aResult: string } & { bResult: string }

Playground

Alex Wayne
  • 178,991
  • 47
  • 309
  • 337