Advanced TypeScript: A Generic Function to Update and Manipulate Object Arrays
Another fancy generic function of ultimate power!
Posted on June 9, 2021
Always Pushing for Cleaner Code
While building my newest SaaS product, ReduxPlate, I realized a common pattern kept cropping up in my array manipulation functions. I was always updating a specific value at a specific key, based on a specific test on some other key.
Plug Zone
Speaking of ReduxPlate, which automatically generates Redux code for you - I'm writing a book, and pairing it with a course, which documents every step I took along the way to build ReduxPlate - from boilerplate starters to the finished live product. I'd love it if you check out any or all of the following links!The book:
The GitHub organization:
The product:
You're reading this correctly! I literally build ReduxPlate from start to finish, right before your eyes - and the code is all public!
For example, for the editor widget on the ReduxPlate homepage, I use a stateful array of type IEditorSettings to determine which editor is currently active and what the actual code value is in the editor:
export default interface IEditorSetting {
fileLabel: string
code: string
isActive: boolean
}
Such behavior required me to write two event handlers:
onChangeCode
for when the code changes:
const onChangeCode = (code: string) => {
setEditorSettingsState(editorSettingsState.map(editorSetting => {
if (editorSetting.isActive) {
editorSetting.code = code
}
return editorSetting
}))
}
and onChangeTab
for when the editor tab changes:
const onChangeTab = (fileLabel: string) => {
setEditorSettingsState(editorSettingsState.map(editorSetting => {
editorSetting.isActive = editorSetting.fileLabel === fileLabel
return editorSetting
}))
}
Examine these two functions closely. With both, I am mapping over a state variable editorSettingsState
and setting a property in the array according to some test condition. In the onChangeCode
, the test condition is if the isActive
property value is true. In onChangeTab
, the test condition is if fileLabel
property value matches the fileLabel
passed in. As opposed to onChangeCode
, onChangeTab
will set the isActive
value for all items in the array.
With a bit of effort, we should be able to implement a generic function that we can use to replace these functions, and more importantly: reuse throughout our applications anywhere we need the same type of functionality.
Rewriting Both Functions for a Better Overview of Their Structure
To get a better idea of the function we will write, let's expand the two functions with an else
statement, while keeping their functionalities exactly the same.
For onChangeCode
:
const onChangeCode = (code: string) => {
setEditorSettingsState(editorSettingsState.map(editorSetting => {
if (editorSetting.isActive) {
editorSetting.code = code
} else {
// do nothing :)
}
return editorSetting
}))
}
and for onChangeTab
:
const onChangeTab = (fileLabel: string) => {
setEditorSettingsState(editorSettingsState.map(editorSetting => {
if (editorSetting.fileLabel === fileLabel) {
editorSetting.isActive = true
} else {
editorSetting.isActive = false
}
return editorSetting
}))
}
In this form, it's clear that our generic function should have some sort of test criteria, which will live in the if
statement. Then we need the key and value of the property which is to be updated in the array if the test criteria passes. Furthermore, what occurs in the else
block should be optional - that is, there should be an optional way to set a default value if the test fails. Really what this means is that this will become an else if
block.
The body of our new generic function would then take on the same type of form as these two expanded functions:
return array.map(item => {
if (item[testKey] === testValue) {
item[updateKey] = updateValue
} else if (testFailValue !== undefined) {
item[updateKey] = testFailValue
}
return item
})
We'll need to provide a testKey
and value as our test criteria, as well as an updateKey
and updateValue
if the test passes. Finally, an optional parameter will be testFailValue
. If testFailValue
is not undefined
, then we will execute the else if
block.
Typing the Function
The most challenging part of writing this function was ensuring that the value passed for testValue
matches the expected type of T[testKey]
. The same should be true for updateValue
/ testFailValue
with T[updateKey]
. With TypeScript, it is possible to do this, though we'll need to explicitly provide a bit of information in the calling signature in order to enforce it. Our array
in question is of type Array<T>
, that much is clear. But what about the types for testKey
and updateKey
? We'll need to introduce two more generic types to get those to work, U
and V
. To ensure that both testKey
and updateKey
are actual keys of object T
, we'll employ TypeScripts's extends
keyword, i.e. defining U
as U extends keyof T
, and V
as V extends keyof T
.
With types U
and V
defined, testKey
and updateKey
can be defined by keyof T
, as well as their corresponding values: testValue
as T[U]
, and updateValue
as T[V]
. testFailValue
follows updateValue
with the identical type T[V]
. Finally, since this is an array function map
, we'll be returning a fresh array of type T
. Because this signature is rather complex, I add them all to a param
object so that when we call this updateArray
function, it will be easy to read and understand. Such a structure also makes it easier to extend and add additional parameters later.
So, we have our function signature:
export const updateArray = <T, U extends keyof T, V extends keyof T>(params: {
array: Array<T>
testKey: keyof T
testValue: T[U]
updateKey: keyof T
updateValue: T[V]
testFailValue?: T[V]
}): Array<T>
Final Result
Hooking in the map
logic from above, the full updateArray
function in full is:
// Updates an object array at the specified update key with the update value,
// if the specified test key matches the test value.
// Optionally pass 'testFailValue' to set a default value if the test fails.
export const updateArray = <T, U extends keyof T, V extends keyof T>(params: {
array: Array<T>
testKey: keyof T
testValue: T[U]
updateKey: keyof T
updateValue: T[V]
testFailValue?: T[V]
}): Array<T> => {
const {
array,
testKey,
testValue,
updateKey,
updateValue,
testFailValue,
} = params
return array.map(item => {
if (item[testKey] === testValue) {
item[updateKey] = updateValue
} else if (testFailValue !== undefined) {
item[updateKey] = testFailValue
}
return item
})
}
A possible improvement to add to this function might be to differentiate between the updateKey
on success and on fail. Perhaps in some rare case you would want to set the value of some other key if the test fails.
Use It!
Let's return to our original functions and refactor them to use our fancy generic function updateArray
.
Referring to IEditorSetting
above may be helpful (recall that editorSettingsState
is an array of IEditorSetting
). Here's the refactored onChangeCode
:
const onChangeCode = (code: string) => {
setEditorSettingsState(updateArray({
array: editorSettingsState,
testKey: "isActive",
testValue: true,
updateKey: "code",
updateValue: code,
}))
}
and onChangeTab
:
const onChangeTab = (fileLabel: string) => {
setEditorSettingsState(updateArray({
array: editorSettingsState,
testKey: "fileLabel",
testValue: fileLabel,
updateKey: "isActive",
updateValue: true,
testFailValue: false,
}))
}
Thanks to our U extends keyof T
and U extends keyof T
, our function is type safe: for example, TypeScript won't allow passing a string like "hello world"
to updateValue
, since the expected type for the IEditorSetting
on the isActive
key is boolean
.
Congratulations, we're done!
Verbosity vs. Reusability and Readability
Indeed, calling updateArray
is rather verbose. However, this is a small price to pay when you consider that we no longer have to think about crafting all those pesky map
manipulations throughout our apps!
Is this an over-optimization? I don't think so - take a look at your own projects using either React or Redux, or both. I guarantee you have the same times of array mapping and manipulations, either in your state changes or render functions!
Thanks!
With this powerful generic function, you should never need to think about map
array manipulations at a property level ever again! Additionally, the strongly typed signature also protects you from passing either a testValue
or updateValue
that doesn't correspond with its respective key's expected type!
Cheers! 🍺
-Chris