Context
An API which we don't own can return an object containing the same property, with a suffix going from 1 to 3. Example:
interface Order {
// some unique keys
id: string;
name: string;
// some keys "grouped" by 3
price1: number;
price2: number;
price3: number;
quantity1: number;
quantity2: number;
quantity3: number;
shippingDate1: string;
shippingDate2: string;
shippingDate3: string;
}
Notes:
- Why is that API not exposing
prices
,quantities
andshippingDates
as arrays is beyond the point here. Let's just assume we consume an API that we cannot modify - This is just an example and the returned type could have way more properties like that, so we don't want to have to make the whole remapping manually
The challenge
While the backend may expose it this way for a good reason, in our case, on the frontend side, we'd rather prefer to have a data structure matching our needs in a better way.
Part 1
Build a getGroupedValues
function which will have
- Input 1: An object
- Input 2: A key for one of the repeated properties. Example if we pass an
Order
as first argument, we could pass as second argument eitherprice
,quantity
orshippingDate
- Output: An array of the type matching the union of all the keys for that common one. Example with
Order
, if the second argument isprice
we'd expect as an outputnumber[]
but if it'sshippingDate
we'd expectstring[]
This could be a building block to do something like this:
interface OrderRemap {
id: string;
name: string;
prices: number[];
quantities: number[];
shippingDates: string[];
}
declare const order: Order; // no need to know where it's coming from for the example
const orderRemap: OrderRemap = {
id: order.id,
name: order.name,
prices: getGroupedValues(order, 'price'),
quantities: getGroupedValues(order, 'quantity'),
shippingDates: getGroupedValues(order, 'shippingDate'),
};
Main goal being: Have that function as type safe as possible.
Part 2
Let's try to build a function that'd handle all the following for us:
const orderRemap: OrderRemap = {
id: order.id,
name: order.name,
prices: getGroupedValues(order, 'price'),
quantities: getGroupedValues(order, 'quantity'),
shippingDates: getGroupedValues(order, 'shippingDate'),
};
Instead, if the types are defined as such:
interface OrderDetail {
price: number;
quantity: number;
shippingDate: string;
}
interface OrderRemapGrouped {
id: string;
name: string;
orderDetails: OrderDetail[];
}
We'd simply have to do:
declare const order: Order; // no need to know where it's coming from for the example
const orderRemapGrouped: OrderRemapGrouped = groupProperties(order, 'orderDetails');
If you want to give those challenges a go yourself before reading the solutions, now is a good time! You can simply open https://www.typescriptlang.org/play and eventually share the URL of your playground as a comment 😄.
Implementation
Part 1
We know this API will always return those properties, 3 times each (e.g. price1
, price2
, price3
).
So we start here by using a recent feature of Typescript: Template literal types.
type Indices = 1 | 2 | 3;
type GroupedKeys<T> = T extends `${infer U}${Indices}` ? U : never;
Pretty cool how easy it is to extract the base keys of all properties that appears 3 times right?
If we test it out, here's the output:
interface Order {
id: string;
name: string;
price1: number;
price2: number;
price3: number;
quantity1: number;
quantity2: number;
quantity3: number;
shippingDate1: string;
shippingDate2: string;
shippingDate3: string;
}
type Indices = 1 | 2 | 3;
type GroupedKeys<T> = T extends `${infer U}${Indices}` ? U : never;
type Result = GroupedKeys<keyof Order>; // "price" | "quantity" | "shippingDate" 🎉
But don't worry, that's not where I was getting at. There's more.
Now let's build our generic getGroupedValues
function (not the implementation as it's not the point) but rather its definition:
function getGroupedValues<Obj, BaseKey extends GroupedKeys<keyof Obj>>(object: Obj, baseKey: BaseKey): Array<Obj[`${BaseKey}${Indices}`]> {
return null as any; // we're not implementing the function, just focusing on its definition
}
It feels like this could be exactly what we want!
- We accept any object type, therefore
Obj
above has no constraint - The base key can be one of grouped keys of that
Obj
, therefore we writeBaseKey extends GroupedKeys<keyof Obj>
- We type the inputs (nothing fancy here):
object: Obj, baseKey: BaseKey
- As for the return type, we know that if we want to get an array of values for the prices (
price1
,price2
,price3
) then thebaseKey
passed as input will beprice
. Therefore if we access in the object the value of${BaseKey}${Indices}
, we'll getprice1
|price2
|price3
which is exactly what we want
Fantastic! All done then! But wait... Typescript doesn't seem to be really happy here. On our return type:
Obj[`${BaseKey}${Indices}`];
It says:
Type '
${BaseKey}1
|${BaseKey}2
|${BaseKey}3
' cannot be used to index type 'Obj'
And it looks legit. We're trying to access properties on a generic which doesn't extends anything (our Obj
type).
But how can we keep this function generic and specify that our object will have keys that are composed of the base key and indices 🤔...
Would
Obj extends Record<`${BaseKey}${Indices}`, any>
Work? Surely it can't work, because BaseKey
is defined after and itself uses Obj
:
Obj extends Record<`${BaseKey}${Indices}`, any>, BaseKey extends GroupedKeys<keyof Obj>
Well this is just fine for Typescript 🤯.
Read this again.
It's fine to say that a given type Obj
will be an object (Record
) which contains keys of another type (BaseKey
), which itself is defined by reading the keys of that Obj
.
Me:
I knew that Typescript could handle recursion just fine, like if you define a list which can have a list, which can have a list, ...
interface List {
propA: number;
list?: List;
}
const list: List = {
propA: 1,
list: {
propA: 2,
list: {
propA: 3,
},
},
};
But this? While I think it's amazing, I'm not sure how Typescript manage to settle on the type.
To wrap this up, here's the complete function (without the implementation but with all the types):
function getGroupedValues<Obj extends Record<`${BaseKey}${Indices}`, any>, BaseKey extends GroupedKeys<keyof Obj>>(
object: Obj,
baseKey: BaseKey,
): Array<Obj[`${BaseKey}${Indices}`]> {
return null as any; // we're not implementing the function, just focusing on its definition
}
If we try it out we see the following:
// some mock for an order
const order: Order = {
id: 'order-1',
name: 'Order 1',
price1: 10,
price2: 20,
price3: 30,
quantity1: 100,
quantity2: 200,
quantity3: 300,
shippingDate1: '10 Oct',
shippingDate2: '11 Oct',
shippingDate3: '12 Oct',
}
const orderRemap: OrderRemap = {
id: order.id,
name: order.name,
prices: getGroupedValues(order, 'price') // inferred return type: `number[]`
quantities: getGroupedValues(order, 'quantity') // inferred return type: `number[]`
shippingDates: getGroupedValues(order, 'shippingDate') // inferred return type: `string[]`
};
I just tried this trick on a Friday afternoon without much hope after being stuck for a while on the error
Type '
${BaseKey}1
|${BaseKey}2
|${BaseKey}3
' cannot be used to index type 'Obj'
I was amazed to see that somehow Typescript is ok with those generics defined at the same level and using each others
Obj extends Record<`${BaseKey}${Indices}`, any>, BaseKey extends GroupedKeys<keyof Obj>
Am I the only one? Did you know about this? Is anyone able to explain how Typescript can be ok with this? In any case leave a comment and tell me what you think about it and if this was useful 😄!
Here's the Typescript Playground link with all the code from above.
Part 2
While creating the API in part 1, we notice that we have to call the getGroupedValues
function multiple times, pass a key, recreate the whole object for the remaining properties, etc. It's quite heavy and... Could be simpler!
So now we'll see how to write a function which does all of this for us and groups the different properties based on their index:
const orderRemapGrouped: OrderRemapGrouped = groupProperties(order, 'orderDetails');
So here, orderDetails
will be an array containing objects of type {price: number, quantity: number; shippingDate: string}
where the values would be coming from the same index. Example for orderDetails[0]
, it'd have the price
, quantity
and shippingDate
of price1
, quantity1
and shippingDate1
. Etc.
function groupProperties<Obj extends Record<`${Keys}${Indices}`, any>, Keys extends GroupedKeys<keyof Obj>, NewKey extends string>(
object: Obj,
newKey: NewKey,
): Omit<Obj, `${Keys}${Indices}`> & Record<NewKey, Array<{[key in Keys]: Obj[`${key}${Indices}`]}>> {
return null as any; // we're not implementing the function, just focusing on its definition
}
See some similarities here?
Exactly! The biggest difference being the return type. So let's break it down:
Omit<Obj, `${Keys}${Indices}`>
First, we know that the new object we return should not have any of the properties with the indices (e.g. price1
, price2
, price3
). So we use the built in Omit
type to exclude from the common keys concatenated to the indices.
Then:
Record<NewKey, Array<{[key in Keys]: Obj[`${key}${Indices}`]}>>
We add to the return one property, which will have the key passed as second parameter of the function (of type NewKey
). That key, will have a value that will be an array of objects.
These objects are going to be all the common keys (price
, quantity
and shippingDate
), associated to the union type of all those properties. For example if we start with price
we'll get the union type of price1
, price2
, price3
which is number
as they're all numbers. And same for the others.
Here's the Typescript Playground link with all the code from above.
Conclusion
I didn't think that referencing 2 generics between an object and its keys (part 1) would work. But it does and for the best. The combination of that trick with template literals to extract the common bit of some properties is quite powerful and gives us robust typings on our functions to change a data structure into a fairly different one.
I just love Typescript ❤️✨. If you do as well and you found this article useful, let me know in the comments. I'd also be really interested to see some attempts/solutions from people who gave it a go!
Found a typo?
If you've found a typo, a sentence that could be improved or anything else that should be updated on this blog post, you can access it through a git repository and make a pull request. Instead of posting a comment, please go directly to https://github.com/maxime1992/my-dev.to and open a new pull request with your changes. If you're interested how I manage my dev.to posts through git and CI, read more here.