DEV Community

loading...
Cover image for Advanced TypeScript: A Generic Function to Update and Manipulate Object Arrays

Advanced TypeScript: A Generic Function to Update and Manipulate Object Arrays

Chris Frewin
Full Stack Software Engineer at Full Stack Craft, fullstackcraft.com
・5 min read

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: Speaking of ReduxPlate, which automatically generates Redux code for you, I'm writing a book that 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 it out! Yes, You've read 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
}  
Enter fullscreen mode Exit fullscreen mode

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
  }))
}
Enter fullscreen mode Exit fullscreen mode

and onChangeTab for when the editor tab changes:

const onChangeTab = (fileLabel: string) => {
  setEditorSettingsState(editorSettingsState.map(editorSetting => {
      editorSetting.isActive = editorSetting.fileLabel === fileLabel
    return editorSetting
  }))
}
Enter fullscreen mode Exit fullscreen mode

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
  }))
}
Enter fullscreen mode Exit fullscreen mode

and for onChangeTab:

const onChangeTab = (fileLabel: string) => {
  setEditorSettingsState(editorSettingsState.map(editorSetting => {
      if (editorSetting.fileLabel === fileLabel) {
        editorSetting.isActive = true
      } else {
        editorSetting.isActive = false
      }
    return editorSetting
  }))
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
  })
}
Enter fullscreen mode Exit fullscreen mode

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,
  }))
}
Enter fullscreen mode Exit fullscreen mode

and onChangeTab:

const onChangeTab = (fileLabel: string) => {
  setEditorSettingsState(updateArray({
    array: editorSettingsState,
    testKey: "fileLabel",
    testValue: fileLabel,
    updateKey: "isActive",
    updateValue: true,
    testFailValue: false,
  }))
}
Enter fullscreen mode Exit fullscreen mode

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!

You may also want to check this snippet out on my Full Stack Snippets page, which has further additional snippet goods like this function!

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

Discussion (5)

Collapse
markerikson profile image
Mark Erikson • Edited

Hi, I'm a Redux maintainer. Unfortunately, I have concerns with several of the things you've shown here in this post, and with the Redux Plate app you linked - the code patterns shown are the opposite of how we recommend people use Redux today.

The first thing I note is that the generated code from ReduxPlate is using very old patterns for action types and file structure It has "handwritten/manual" logic for action creators and reducers, and each of the different code types is being output in a separate file.

Instead, we recommend using our official Redux Toolkit package, which is the standard approach for writing Redux logic. RTK simplifies existing Redux code patterns, and is specifically designed for a good TS usage experience. You can see our recommended RTK+TS usage patterns here. We also recommend using a "feature folder" structure, with single-file "slices" for logic.

Using RTK and its createSlice utility eliminates the need to have hand-written action creators, or split that logic across multiple files, and the action types become a background implementation detail that you don't even have to think about.

Next, the generated code is defining actions as a bunch of "setter functions", like SET_FIRST_NAME. We specifically recommend that you should model actions as "events", not "setters". Modeling actions as events leads to fewer actions being dispatched, and a more readable and semantic action history. It also means there's a lot less code to write.

RTK also uses Immer inside to let you write "mutating" state update logic in reducers. That drastically simplifies the update logic.

So, while I'm always happy to see people building things with Redux, I'd really encourage to you rework the code generation and the patterns you're working with to use Redux Toolkit instead, and follow our recommended best practices. That will make things much easier for you and the people using what you've built.

Collapse
Sloan, the sloth mascot
Comment deleted
Collapse
markerikson profile image
Mark Erikson

To be honest, almost all the work on our TS types over the last couple years has been done by RTK maintainer Lenz Weber ( twitter.com/phry ). I've picked up enough TS to be able to follow some of what goes on, but there's still a lot of stuff in the codebase that's over my head :)

If you're interested in a discussion on the typings, I'd suggest talking to Lenz first.

Thread Thread
pwnball profile image
Daan van der Burgt

Oops, I deleted my post, but it can not be undone it seems, bit quick on the trigger there.

Still much appreciate the quick reply! RTK seems a fine piece of work, looking forward to diving into it. Angular is my home turf, so also looking forward to seeing the toolkit implemented in NgRx!

Keep up the good work and stay frosty :)

Collapse
fullstackchris profile image
Chris Frewin Author • Edited

Of course. I agree with all of this! At the same time, I can garuantee there are a huge number of companies that still use these deprecated patterns in their legacy code bases. This type of generation would be for them. My plan is to ultimately make ReduxPlate a new abstraction layer on top of Redux Toolkit. Additionally, I don't want to give too much of the secret sauce away, so that's why I've leaned on this deprecated generation pattern for the public facing example.

To be fair, it's probably worthwhile to note all these things in a disclaimer on the homepage.

Also, the action generation will be a main challenge and feature of ReduxPlate - can we merge actions? Add more than just the setting of a single property in state with them? This will depend a lot on customer's use cases and their own applications and code bases. All of these will eventually be possible with ReduxPlate.