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.
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.