Suppose we have an object, and one of its fields an array. Taking every array
elements and adding the rest of the objects keys is exploding the object
w.r.t that array element. For example, if we have an object {university: "XYZ", batch: 2019, students: [{name: "Alice"}, {name: "Bob"}]}
, exploding it
w.r.t students
results in [{university: "XYZ", batch: 2019, name: "Alice"}, {university: "XYZ", batch: 2019, name: "Bob"}]
.
We can encounter the need for the explosion in many cases. For example, let’s have an e-commerce order object, where it contains the individual items in an array. When we want to process individual items with the order details, explosion is the suitable operation.
With understanding the explode operation, let’s proceed to define our goals clearly w.r.t to the result and type-safety
The Goals
- We want to have a function
explode(object, key)
which does the explosion - The
key
not being present inobject
should result in type error - The
key
not being array should result in type error - The returned array must be appropriately typed
We are resorting to type errors in most of the cases as we can be confident about the function inputs when we run the code inside our app. If we want to ship this function in a library, we need to do runtime checks as well, as there is a possibility of this code called in plain JS without any typechecking. In this post, for the sake of breivity, We are omiting the runtime checks.
Solution without Types
The problem of explosion is easy and straightforward - we extract the value at the key, map over the array and add rest of the properties to every object in it.
const explode = (object, key) => {
const { [key]: array, ...rest } = object
return array.map((item) => ({
...item,
...rest,
}))
}
Moving to the Type Realm
Now, let’s add the types to the explode
function, starting with the basics to
match the JS schematics:
const explode = (object: object, key: string) => {
const { [key]: array, ...rest } = object
return array.map((item) => ({
...item,
...rest,
}))
}
TS yells us at about two problems here:
-
We don’t know if the
key
is indeed a key of theobject
-
By virtue of that,
array
has typeany
, which makesitem
implicitlyany
.
Let’s first restrict key
to be a valid key of object
. Since our parameters
have dependency now, we need to make our function generic. If the generic
parameter is T
, we can either mark object
or key
as T
. For the reasons
that will become evident later, let’s mark the object
as T
. With that,
let’s also add generic constraint on T
to be a subtype of object
.
const explode = <T extends object>(object: T, key: keyof T) => {
const { [key]: array, ...rest } = object
return array.map((item) => ({
...item,
...rest,
}))
}
With this, we restrict key
to be a valid key of T
, not any string. This
solves the problem #1. We still need to solve problem #2. Before doing that,
let’s first restrict key
to only the keys where value is an array.
Hello, Key Mapping!
Let’s first understand how keyof T
looks like before filtering it:
type UniversityRecord = {
university: string
batch: number
students: { name: string }[]
}
type Keys = keyof UniversityRecord
// "university" | "batch" | "students"
Thus, Keys
is a union of strings, and it does not recursively descend into
the object structure, which is as expected. Now, can we loop over the union?
Unfortunately not. To understand why, remember there is no order in
unions - a | b | c
as b | c | a
and etc. To loop over the keys and filter
them out, we can use key
remapping
type ArrayKeys<T extends object> = keyof {
[K in keyof T as T[K] extends any[] ? K : never]: unknown
}
There are so many things going on here even if the code is deceptivly small. Let’s break down the things happening here:
- We are looping over every key
K
inkeyof T
in a mapping context (inside[]
). Remember that such loop was not possible with unions. - We are remapping the key using
as
keyword. If we map the key tonever
, it will be excluded - Remapping supports conditions too - we check if
T[K]
is an array. If so, we keepK
as it is; otherwise, we will exclude it usingnever
.
Using this type helper, we can now define explode
function as:
const explode = <T extends object>(object: T, key: ArrayKeys<T>) => {
const { [key]: array, ...rest } = object
return array.map((item) => ({
...item,
...rest,
}))
}
Now, we have to solve one problem: TS still does not know that object[key]
is an array. While we can do further type manipulations, we can use runtime
functions to solve this.
const explode = <T extends object>(object: T, key: ArrayKeys<T>) => {
const { [key]: array, ...rest } = object
return Array.isArray(array) ? array.map((item) => ({ ...item, ...rest })) : []
}
By adding Array.isArray
, the array
is treated as any[]
. TS defines
isArray
as a type
predicate
with the following declaration:
function isArray(arg: any): arg is any[]
When isArray
returns true
, TS knows that the arg
is any[]
, so we can
access map
and item
is infered as any
. Let’s try our new function
definition to check if argument types work properly:
const res1 = explode(
{
university: 'XYZ',
batch: 2019,
students: [{ name: 'Alice' }, { name: 'Bob' }],
},
'university'
) // type error
const res2 = explode(
{
university: 'XYZ',
batch: 2019,
students: [{ name: 'Alice' }, { name: 'Bob' }],
},
'students'
) // no error
Well, so far so good. But if we check the return type of explode
, it is
any[]
, which makes it less useful in practice. Let’s solve that by adding a
return type to it.
Enter Type Gymnastics
We have following information about explode(object, key)
:
- It returns an array (well, TS is intelligent to know that already, as it
infers
any[]
as the return type) - Every element of the array is an object.
- The object is derived by concatenating two objects:
object
withkey
removed- the type of elements of
object[key]
With this information, we can write a type helper Explode
:
type Explode<T extends object, K extends keyof T> = T[K] extends any[]
? (Omit<T, K> & T[K][number])[]
: never
Few interesting things are happening here:
- We constrint
K
to bekeyof T
, notArrayKeys<T>
. UsingArrayKeys
here does not add any useful information as TS does not get to knowT[K]
is an array. Thus, we choose a simpler path. - We do the check
T[K] extends any[]
again, due to the reason explained above. If it does not, we returnnever
. This case never happens ifK extends ArrayKeys<T>
- If the check passes, we omit
K
fromT
and intersect with the constituent type ofT[K]
- To get the constituent type of
T[K]
, we index it onnumber
, as arrays are indexed based on number
Now, we can proceed to type the explode
function completly:
const explode = <T extends object, K extends ArrayKeys<T>>(
object: T,
key: K
) => {
const { [key]: array, ...rest } = object
return (
Array.isArray(array)
? array.map((item) => ({
...item,
...rest,
}))
: []
) as Explode<T, K>
}
It looks little bit different from our above definition. Apart from the as Explode<T,K>
cast, we typed key
with K
where K extends ArrayKeys<T>
.
Typing key
as ArrayKeys<T>
and passing typeof key
to Explode
will keep
the generic argument to Explode
as ArrayKeys<T>
, not the specific literal
type that is passed in key
. With this, we can proceed to explode!
Testing
Let’s call explode
few times to check if everything works well. For testing,
let’s assume we have an e-commerce order object:
const order = {
type: 'Order',
customer: {
name: 'Alice',
address: '123 st.',
},
items: [
{
id: '111',
name: 'Long sleeve T Shirt',
price: 15,
currency: 'USD',
},
{
id: '232',
name: 'Jeans',
price: 12,
currency: 'USD',
},
],
offers: [
{
code: 'FIRST-CUSTOMER',
discountPercentage: '20',
},
{
code: 'REFFERAL',
discountPercentage: '10',
},
],
}
As per our expectations, explode
on this object should work for items
and
offers
, but not on other keys:
const result1 = explode(order, 'type') // type error
const result2 = explode(order, 'items') // works
/*
const result2 = [
{
id: "111",
name: "Long sleeve T Shirt",
price: 15,
currency: "USD",
type: "Order",
customer: {
name: "Alice",
address: "123 st.",
},
offers: [
{
code: "FIRST-CUSTOMER",
discountPercentage: "20",
},
{
code: "REFFERAL",
discountPercentage: "10",
},
],
},
{
id: "232",
name: "Jeans",
price: 12,
currency: "USD",
type: "Order",
customer: {
name: "Alice",
address: "123 st.",
},
offers: [
{
code: "FIRST-CUSTOMER",
discountPercentage: "20",
},
{
code: "REFFERAL",
discountPercentage: "10",
},
],
},
]
*/
const result3 = explode(order, 'offers') // works
/*
const result3 = [
{
code: "FIRST-CUSTOMER",
discountPercentage: "20",
type: "Order",
customer: {
name: "Alice",
address: "123 st.",
},
items: [
{
id: "111",
name: "Long sleeve T Shirt",
price: 15,
currency: "USD",
},
{
id: "232",
name: "Jeans",
price: 12,
currency: "USD",
},
],
},
{
code: "REFFERAL",
discountPercentage: "10",
type: "Order",
customer: {
name: "Alice",
address: "123 st.",
},
items: [
{
id: "111",
name: "Long sleeve T Shirt",
price: 15,
currency: "USD",
},
{
id: "232",
name: "Jeans",
price: 12,
currency: "USD",
},
],
},
]
*/
We can also check the output types of result2
and result3
type Simplify<T> = T extends object
? {
[K in keyof T]: Simplify<T[K]>
}
: T
type Result2 = Simplify<typeof result2>
/*
type Result2 = {
type: string
customer: {
name: string
address: string
}
offers: {
code: string
discountPercentage: string
}[]
id: string
name: string
price: number
currency: string
}[]
*/
type Result3 = Simplify<typeof result3>
/*
type Result3 = {
type: string
customer: {
name: string
address: string
}
items: {
id: string
name: string
price: number
currency: string
}[]
code: string
discountPercentage: string
}[]
*/
We can see that the type result matches the type of execution result, which
made result2
and result3
strongly typed, and, caught invalid key in
result1
, making our explode
function robust in type domain.