1

I am writing an API wrapper library in TypeScript and I want TypeScript to be able to give precise return type information based on the user inputs for an API call. I also want to transform the API response into something more usable for the library consumer.

Imagine I have an API endpoint with a return type someType:

type someType = {
    statuses?: string[],  // 
    distances?: number[]  // all arrays will be of equal length
}

Where the arrays are always of the same length and correspond to the same feature at a given index. So I would like to transform the response into an array of type someOtherType:

type someOtherType = {
  status?: string,
  distance?: number
}

The user is given control over what properties are going to be on the response someType based on an array of someType keys. So I would like to enable TypeScript to know which properties are going to be on the returned objects and which ones aren't. I have tried the following but to no avail:

type someTypeKeys = keyof someType
type someMappedTypeKeys = keyof someOtherType

const Mapping: Record<someTypeKeys, someMappedTypeKeys> = {
  statuses: "status",
  distances: "distance"
}

const someObj: someType = {
    statuses: ["1"],
}

type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>
type FunctionReturnType<T extends keyof someType> = Required<Pick<someOtherType, typeof Mapping[T]>>



function a<T extends (keyof someType)[]>(arg: T): Required<Pick<someOtherType, typeof Mapping[typeof arg[number]]>>[] {
  const someResponseObj = { // some imaginary response
    statuses: ["1"],
  } as unknown as Required<Pick<someType, typeof arg[number]>>

  
  const arr: Required<Pick<someOtherType, typeof Mapping[typeof arg[number]]>>[]  = []
 

  for (let i = 0; i < (someObj.statuses.length) && i ; ++i) {
      const mappedObj = arg.reduce((agg, key) => {
        const mappedKey = Mapping[key]
        const currArr = someObj[key]
        if (currArr === undefined) {
          return agg
        } else {

          return {...agg, [mappedKey]: currArr[i]}
        }
        
      }, {}) as Required<Pick<someOtherType, typeof Mapping[typeof arg[number]]>>

      arr.push(mappedObj)

  }

  return arr;
}

const b = a(["statuses"])
b[0].distance // should be undefined
b // can I make TS be able to tell the properties available on objects in array b? 
Chris
  • 53
  • 1
  • 7
  • 1
    Does [this approach](https://tsplay.dev/w22lxw) meet your needs? If so I could write up an answer explaining; if not, what am I missing? – jcalz Apr 26 '23 at 18:58
  • Thanks @jcalz, this is really helpful. Only thing was I needed to remove the optional property modifier in the output type, because property keys not passed in the array will not be present in the outputs: https://tsplay.dev/mL8x4m – Chris Apr 28 '23 at 13:30
  • I'm not sure what to say about that; what do you want to see happen if `someType` doesn't have one of the keys you ask for? Shouldn't it still be optional in the result? See [this playground link](https://tsplay.dev/w1ndyN). If `SomeType` has optional properties then the output of `a` should preserve that optionality. Do you agree or disagree (and if so why?) And should I write this up as an answer or not? – jcalz Apr 28 '23 at 13:44
  • `SomeType` refers to additional properties that can be returned by an external API if the user wishes to include them. So the input array `keys` always reflects the exact keys that will be present on the returned properties object. That's why in this particular case I see no point in including the other keys as optional. It's just the way that external API works (and I have no power over that, my aim here is to just provide a better developer experience). And yes, whether or not we agree over this detail, you generally solved my question, so I'll look forward to this as an answer :-) – Chris Apr 28 '23 at 14:27
  • There's probably no point in `SomeType`'s properties being optional then, since you're always going to do the equivalent of a `Pick` on it. I mean, I don't care if you have some original type with optional properties, but inside the implementation of `a` you'd only want to deal with `Required` or `Pick>` or the like. See [here](https://tsplay.dev/mA8e8N). Anyway I'll write up an answer when I get a chance. – jcalz Apr 28 '23 at 14:40

2 Answers2

1

Given the input type

type SomeType = {
  statuses: string[],
  distances: number[]
}

and the mapping type

const mapping = {
  statuses: "status",
  distances: "distance"
} as const;

type Mapping = typeof mapping;

we would like to express the call signature of a to be

declare function a<K extends keyof SomeType>(keys: K[]): OutputType<K>[];

where OutputType<K> represents the element type of the output array for a given set of keys of type K[].


We will build it with some helper types:

type MapKeys<T, M extends Record<keyof T, PropertyKey>> =
  { [K in keyof T as M[K]]: T[K] }

type PropElements<T extends { [K in keyof T]?: readonly any[] }> =
  { [K in keyof T]: Exclude<T[K], undefined>[number] };

MapKeys<T, M> uses key remapping to change the keys of T to those in the mapping M without changing the values. And PropElements<T> changes the property values of an object-with-array-property-values to an object holding just the element types.

Then OutputType<K> would be:

type OutputType<K extends keyof SomeType> =
  MapKeys<PropElements<Pick<SomeType, K>>, Mapping>;

Where we pick the keys of SomeType from K, get their array element types with PropElements, and then map the keys with MapKeys and Mapping. Unfortunately that will end up being displayed as-is with IntelliSense. To make it look like a plain object type instead, we'll use a trick from How can I see the full expanded contract of a Typescript type? :

type OutputType<K extends keyof SomeType> =
  MapKeys<PropElements<Pick<SomeType, K>>, Mapping> extends infer O {
    [P in keyof O]: O[P]
  } : never;

Let's test it out:

type S = OutputType<"statuses">;
// type S = {  status: string; } 
type D = OutputType<"distances">;
// type D = {  distance: number; }
type SD = OutputType<"statuses" | "distances">;
// type SD = { status: string; distance: number; }

And make sure it works as advertised when you call a():

const s = a(["statuses"]);
// const s: { status: string; }[] 

const d = a(["distances"]);
// const d: { distance: number; }[] 

const sd = a(["statuses", "distances"]);
// const sd: { status: string; distance: number; }[]

That's the typing. As for the implementation, that's probably up to your use case; here's a possible approach:

function a<K extends keyof SomeType>(keys: K[]): OutputType<K>[] {

  // this comes from somewhere
  const someType: SomeType = {
    statuses: ["a", "b", "c", "d"],
    distances: [1, 2, 3, 4]
  }

  const arrLen = Math.min(...Object.values(someType).
    filter(x => x).map(x => x.length));
  const ret: any[] = [];
  for (let i = 0; i < arrLen; i++) {
    const retObj: any = {};
    for (const k of keys) {
      const ok = someType[k];
      if (ok) retObj[mapping[k]] = ok[i];
    }
    ret.push(retObj);
  }
  return ret;
}

It's just finding out the array length of the output and then walking through the input object properties and copying and mapping keys. I'm more concerned with the call signature typing than the correctness and type verification inside the implementation, so you should make sure that you make any changes required to meet your needs.

For completeness though, here's what comes out:

const s = a(["statuses"]);
console.log(s);
// [{ "status": "a" }, { "status": "b" }, { "status": "c" }, { "status": "d" }] 

const sd = a(["statuses", "distances"]);
console.log(sd);
// [{ "status": "a", "distance": 1 }, { "status": "b", "distance": 2 }, 
//  { "status": "c", "distance": 3 }, { "status": "d",  "distance": 4 }] 

const d = a(["distances"]);
console.log(d);
// [{ "distance": 1 }, { "distance": 2 }, { "distance": 3 }, { "distance": 4 }]

Looks good!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
0

https://tsplay.dev/NB8lgW

type someType = {
    statuses?: string[],  // 
    distances?: number[]  // all arrays will be of equal length
}

type someOtherType = {
    status?: string,
    distance?: number
}

type someTypeKeys = keyof someType
type someMappedTypeKeys = keyof someOtherType

const Mapping = {
    statuses: "status",
    distances: "distance"
} satisfies Record<someTypeKeys, someMappedTypeKeys>
// or `as const`

const someObj: someType = {
    statuses: ["1"],
}

type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>
type FunctionReturnType<T extends keyof someType> = Required<Pick<someOtherType, typeof Mapping[T]>>

function mapArrayObjectToObjectArray<
    Source extends Record<PropertyKey, any[]>,
    Mapping extends Record<keyof Source, string>,
    Keys extends keyof Source = keyof Source,
>(
    source: Source,
    mapping: Mapping,
    keys: Keys[]
): { [K in Keys as Mapping[K]]-?: Source[K][number] }[] {
    let length = Object.values(source)[0].length
    return Array.from(
        { length },
        (_, index) => {
            return Object.fromEntries(
                keys.map(k => [mapping[k], source[k][index]])
            ) as any
        }
    )
}

const someResponseObj = { // some imaginary response
    statuses: ["1"],
} satisfies someType

const b = mapArrayObjectToObjectArray(someResponseObj, Mapping, ["statuses"])
//    ^?
// const b: {
//     status: string;
// }[]
b[0].distance // should be undefined
b // can I make TS be able to tell the properties available on objects in array b? 

Dimava
  • 7,654
  • 1
  • 9
  • 24