typescripttypescript-typings

Set type of return key(s) from value(s) appearing in a passed Array


I am writing an api-library and have an interface where certain keys can change depending on what is presented during the request, i.e if certain parameters are added, a key can change from a string type to an object type:

interface MyObject {
  name: string;
  email: string;
}

interface MyAddress {
  street: string;
  state: string;
  country: string;
}

interface MyPerson {
  id: string;
  personalDetails: string | MyObject;
  address: string | MyAddress;
}

type ExpandableFields = 'address' | 'personalDetails';

Lets say the user calls library.person.get(), they should get:

{
  "id": "id_123456",
  "personalDetails": "id_7890123",
  "address": "id_4567890"
}

However, if they call library.person.get({ expand: ['personalDetails', 'address'] })

They should get:

{
  "id": "id_123456",
  "personalDetails": {
    "name": "Joe",
    "email": "joe@shmo.com"
  },
  "address": {
    "street": "fake st",
    "state": "Fake",
    "Country": "FK"
  }
}

If I try to do this with just a Union, when I go to get personalDetails.name, I get the error that "name" does not exist on type "string". If there were only 2 properties with this, I would just type check before sending the response and type the response appropriately. However, there are 6 possible fields that can do this, and any combination of those 6, which would make it quite messy to try to type correctly when sending back.

So my question: is there a way to check if a specific field exists in the expand array passed, and then add/change the types in the interface based on what is passed? I thought of starting an interface without those fields and then adding them based on what the args were:

interface MyPerson {
  id: string;
}

interface MyPersonWithAddressId extends MyPerson {
  address: string;
}

interface MyPersonWithAddress extends MyPerson {
  address: MyAddress
}

But, as mentioned, this would be way too messy to do with 6 properties and all of their possible combinations.


Solution

  • I assume that we only care about typings and not about implementation. One approach is to make get() generic in the type K of the elements of expand (which should be constrained to ExpandableFields), and then map over the properties P of MyPerson and either Exclude or Extract string from the union depending on whether or not P is part of K. Like this:

    interface Person {
        get<K extends ExpandableFields = never>(
            opts?: { expand: K[] }
        ): { [P in keyof MyPerson]:
                P extends K ? Exclude<MyPerson[P], string> : Extract<MyPerson[P], string>
            };
    }
    
    declare const library: { person: Person }
    

    Then you can verify that it behaves as expected:

    const a = library.person.get()
    /* const a: {
        id: string;
        personalDetails: string;
        address: string;
    } */
    
    const b = library.person.get({ expand: ['personalDetails', 'address'] })
    /* const b: {
        id: string;
        personalDetails: MyObject;
        address: MyAddress;
    } */
    

    And this should scale well to six or more properties.

    Playground link to code