Find me on medium
JavaScript is widely known for being extremely flexible by its nature. This post will show some examples of taking advantage of this by working with functions.
Since functions can be passed around anywhere, we can pass them into the arguments of functions.
My first hands-on experience with anything having to do with programming in general was getting started with writing code in JavaScript, and one concept in practice that was confusing to me was passing functions into other functions. I tried to do some of this "advanced" stuff that all the pros were doing but I kept ending up with something like this:
function getDate(callback) {
return callback(new Date())
}
function start(callback) {
return getDate(callback)
}
start(function (date) {
console.log(`Todays date: ${date}`)
})
This was absolutely ridiculous, and even made it more difficult to understand why we would even pass functions into other functions in the real world, when we could have just done this and get the same behavior back:
const date = new Date()
console.log(`Todays date: ${date}`)
But why isn't this good enough for more complex situations? What is the point of creating a custom getDate(callback)
function and having to do extra work, besides feeling cool?
I then proceeded to ask about more questions about these use cases and asked to be given an example of a good use on a community board, but no one wanted to explain and give an example.
Thinking back from now, I realized that the problem was that my mind did not know how to think programmatically yet. It takes awhile to get your mind shifted from your original life towards programming in a computer language.
Since I understand the frustrations of trying to understand when higher order functions are useful in JavaScript, I decided to write this article to explain step by step on a good use case starting from a very basic function which anyone can write, and work our way up from there into a complex implementation that provides additional benefits.
The function with intention
First we will start with a function that is intended to achieve a goal for us.
How about a function that will take an object and return a new object that updated the styles the way we wanted it to?
Lets work with this object (we will reference this as a component):
const component = {
type: 'label',
style: {
height: 250,
fontSize: 14,
fontWeight: 'bold',
textAlign: 'center',
},
}
We want to make our function keep the height
no less than 300
and apply a border
to button components (components with type: 'button'
) and return it back to us.
This can look something like this:
function start(component) {
// Restrict it from displaying in a smaller size
if (component.style.height < 300) {
component.style['height'] = 300
}
if (component.type === 'button') {
// Give all button components a dashed teal border
component.style['border'] = '1px dashed teal'
}
if (component.type === 'input') {
if (component.inputType === 'email') {
// Uppercase every letter for email inputs
component.style.textTransform = 'uppercase'
}
}
return component
}
const component = {
type: 'div',
style: {
height: 250,
fontSize: 14,
fontWeight: 'bold',
textAlign: 'center',
},
}
const result = start(component)
console.log(result)
Result:
{
"type": "div",
"style": {
"height": 300,
"fontSize": 14,
"fontWeight": "bold",
"textAlign": "center"
}
}
Lets pretend we came up with an idea that each component can have more components inside of it by placing them inside its children
property. That means we have to make this handle the inner components as well.
So, given a component like this:
{
"type": "div",
"style": {
"height": 300,
"fontSize": 14,
"fontWeight": "bold",
"textAlign": "center"
},
"children": [
{
"type": "input",
"inputType": "email",
"placeholder": "Enter your email",
"style": {
"border": "1px solid magenta",
"textTransform": "uppercase"
}
}
]
}
Our function obviously isn't capable of getting the job done, yet:
function start(component) {
// Restrict it from displaying in a smaller size
if (component.style.height < 300) {
component.style['height'] = 300
}
if (component.type === 'button') {
// Give all button components a dashed teal border
component.style['border'] = '1px dashed teal'
}
if (component.type === 'input') {
if (component.inputType === 'email') {
// Uppercase every letter for email inputs
component.style.textTransform = 'uppercase'
}
}
return component
}
Since we recently added in the concept of children to components, we now know there are at least two different things going on to resolve the final result. This is a good time to start thinking about abstraction. Abstracting away pieces of code into reusable functions makes your code more readable and maintainable because it prevents troublesome situations like debugging some issue in the implementation details of something.
When we abstract small parts away from something it's also a good idea to begin thinking about how to put these pieces together later, which we can refer to as composition.
Abstraction and Composition
To know what to abstract away, think about what our end goal was:
"A function that will take an object and return a new object that updated the styles on it the way we want it to"
Essentially the whole point of this function is to transform a value to be in the representation we expect it to. Remember that our original function was transforming styles of a component but then we also added in that components could also contain components within themselves by its children
property, so we can start with abstracting those two parts away since there's a good chance there will most likely be more situations where we need to make more functions that need to do similar things to the value. For the sake of this tutorial can refer to these abstracted functions as resolvers:
function resolveStyles(component) {
// Restrict it from displaying in a smaller size
if (component.style.height < 300) {
component.style['height'] = 300
}
if (component.type === 'button') {
// Give all button components a dashed teal border
component.style['border'] = '1px dashed teal'
}
if (component.type === 'input') {
if (component.inputType === 'email') {
// Uppercase every letter for email inputs
component.style.textTransform = 'uppercase'
}
}
return component
}
function resolveChildren(component) {
if (Array.isArray(component.children)) {
component.children = component.children.map((child) => {
// resolveStyles's return value is a component, so we can use the return value from resolveStyles to be the the result for child components
return resolveStyles(child)
})
}
return component
}
function start(component, resolvers = []) {
return resolvers.reduce((acc, resolve) => {
return resolve(acc)
}, component)
}
const component = {
type: 'div',
style: {
height: 250,
fontSize: 14,
fontWeight: 'bold',
textAlign: 'center',
},
children: [
{
type: 'input',
inputType: 'email',
placeholder: 'Enter your email',
style: {
border: '1px solid magenta',
},
},
],
}
const result = start(component, [resolveStyles, resolveChildren])
console.log(result)
Result:
{
"type": "div",
"style": {
"height": 300,
"fontSize": 14,
"fontWeight": "bold",
"textAlign": "center"
},
"children": [
{
"type": "input",
"inputType": "email",
"placeholder": "Enter your email",
"style": {
"border": "1px solid magenta",
"textTransform": "uppercase"
}
}
]
}
Breaking changes
Next lets talk about how this code can cause catastrophic errors--errors that will crash your app.
If we take a close look at the resolvers and look at how they're being used to compute the final result, we can tell that it can easily break and cause our app to crash because of two reasons:
- It mutates - What if an unknown bug were to occur and mutated the value incorrectly by mistakenly assigning undefined values to the value? The value also fluctuates outside the function because it was mutated (understand how references work).
If we take out return component
from resolveStyles
, we're immediately confronted with a TypeError
because this becomes the incoming value for the next resolver function:
TypeError: Cannot read property 'children' of undefined
-
Resolvers override previous results - This is not a good practice and defeats the purpose of abstraction. Our
resolveStyles
can compute its values but it won't matter if theresolveChildren
function returns an entirely new value.
Keeping things immutable
We can safely move towards our goal by making these functions immutable and ensure that they always return the same result if given the same value.
Merging new changes
Inside our resolveStyles
function we could return a new value (object) containing the changed values that we will merge along with the original value. This way we can ensure that resolvers do not override eachother, and returning undefined
will take no effect for other code afterwards:
function resolveStyles(component) {
let result = {}
// Restrict it from displaying in a smaller size
if (component.style.height < 300) {
result['height'] = 300
}
if (component.type === 'button') {
// Give all button components a dashed teal border
result['border'] = '1px dashed teal'
}
if (component.type === 'input') {
if (component.inputType === 'email') {
// Uppercase every letter for email inputs
result['textTransform'] = 'uppercase'
}
}
return result
}
function resolveChildren(component) {
if (Array.isArray(component.children)) {
return {
children: component.children.map((child) => {
return resolveStyles(child)
}),
}
}
}
function start(component, resolvers = []) {
return resolvers.reduce((acc, resolve) => {
return resolve(acc)
}, component)
}
When a project becomes bigger
If we had 10 style resolvers and only 1 resolver working on children, it can become difficult to maintain so we can split them up in the part where they become merged:
function callResolvers(component, resolvers) {
let result
for (let index = 0; index < resolvers.length; index++) {
const resolver = resolvers[index]
const resolved = resolver(component)
if (resolved) {
result = { ...result, ...resolved }
}
}
return result
}
function start(component, resolvers = []) {
let baseResolvers
let styleResolvers
// Ensure base resolvers is the correct data type
if (Array.isArray(resolvers.base)) baseResolvers = resolvers.base
else baseResolvers = [resolvers.base]
// Ensure style resolvers is the correct data type
if (Array.isArray(resolvers.styles)) styleResolvers = resolvers.styles
else styleResolvers = [resolvers.styles]
return {
...component,
...callResolvers(component, baseResolvers),
style: {
...component.style,
...callResolvers(component, styleResolvers)),
},
}
}
The code that calls these resolvers have been abstracted out into its own function so we can reuse it and also to reduce duplication.
What if we have a resolvers that need some more context to compute its result?
For example, what if we have a resolveTimestampInjection
resolver function that injects a time
property when some options parameter was used passed somewhere in the wrapper?
Functions needing additional context
It would be nice to give resolvers the ability to get additional context and not just receive the component
value as an argument. We can provide this ability with using the second parameter of our resolver functions, but I think those parameters should be saved for lower level abstractions on a component level basis.
What if resolvers had the ability to return a function and receive the context they need from the returned function's arguments instead?
Something that looks like this:
function resolveTimestampInjection(component) {
return function ({ displayTimestamp }) {
if (displayTimestamp === true) {
return {
time: new Date(currentDate).toLocaleTimeString(),
}
}
}
}
It would be nice if we can enable this functionality without changing the behavior of the original code:
function callResolvers(component, resolvers) {
let result
for (let index = 0; index < resolvers.length; index++) {
const resolver = resolvers[index]
const resolved = resolver(component)
if (resolved) {
result = { ...result, ...resolved }
}
}
return result
}
function start(component, resolvers = []) {
let baseResolvers
let styleResolvers
// Ensure base resolvers is the correct data type
if (Array.isArray(resolvers.base)) baseResolvers = resolvers.base
else baseResolvers = [resolvers.base]
// Ensure style resolvers is the correct data type
if (Array.isArray(resolvers.styles)) styleResolvers = resolvers.styles
else styleResolvers = [resolvers.styles]
return {
...component,
...callResolvers(component, baseResolvers),
style: {
...component.style,
...callResolvers(component, styleResolvers)),
},
}
}
const component = {
type: 'div',
style: {
height: 250,
fontSize: 14,
fontWeight: 'bold',
textAlign: 'center',
},
children: [
{
type: 'input',
inputType: 'email',
placeholder: 'Enter your email',
style: {
border: '1px solid magenta',
},
},
],
}
const result = start(component, {
resolvers: {
base: [resolveTimestampInjection, resolveChildren],
styles: [resolveStyles],
},
})
This is where the power of composing higher order functions begin to shine, and the good news is that they're easy to implement!
Abstracting away the abstractions
To enable this functionality, lets move one step higher in the abstraction by wrapping the resolvers into a higher order function that is responsible for injecting the context to the lower level resolver functions.
function makeInjectContext(context) {
return function (callback) {
return function (...args) {
let result = callback(...args)
if (typeof result === 'function') {
// Call it again and inject additional options
result = result(context)
}
return result
}
}
}
We can now return a function from any function that we register as a resolver and still maintain the behavior of our app the same, like so:
const getBaseStyles = () => ({ baseStyles: { color: '#333' } })
const baseStyles = getBaseStyles()
const injectContext = makeInjectContext({
baseStyles,
})
function resolveTimestampInjection(component) {
return function ({ displayTimestamp }) {
if (displayTimestamp === true) {
return {
time: new Date(currentDate).toLocaleTimeString(),
}
}
}
}
Before I show the final example, lets go over the makeInjectContext
higher order function and go over what it is doing:
It first takes an object that you want to be passed to all the resolver functions and returns a function that takes a callback function as an argument. This callback parameter will later become one of the original resolver functions. The reason why we do this is because we're doing whats referred to as wrapping. We wrapped the callback with an outer function so that we can inject additional functionality while still maintaining the behavior of our original function by ensuring that we call the callback inside here. If the return type of the callback's result is a function, we will assume that callback needs the context so we call the callback's result one more time--and that is where we pass in the context.
When we call that callback (a function provided by the caller) and do some computation inside the wrapper function, we have values coming from the wrapper and from the caller. This is a good use case for our end goal because we wanted to merge results together instead of enabling each resolver function the ability to override a value or result from a previous resolver function! It's worth nothing that there are other advanced use cases to solve different problems, and this is a good example to showcase a situation where we needed the right strategy to use for the right situation--because if you're like me, you probably tried to implement a lot of advanced use cases every time you see an open opportunity--which is bad practice because some advanced patterns are better than others depending on the situation!
And now our start
function needs to adjust for the makeInjectContext
higher order function:
const getBaseStyles = () => ({ baseStyles: { color: '#333' } })
function start(component, { resolvers = {}, displayTimestamp }) {
const baseStyles = getBaseStyles()
// This is what will be injected in the returned function from the higher order function
const context = { baseStyles, displayTimestamp }
// This will replace each original resolver and maintain the behavior of the program to behave the same by calling the original resolver inside it
const enhancedResolve = makeInjectContext(context)
let baseResolvers
let styleResolvers
// Ensure base resolvers is the correct data type
if (Array.isArray(resolvers.base)) baseResolvers = resolvers.base
else baseResolvers = [resolvers.base]
// Ensure style resolvers is the correct data type
if (Array.isArray(resolvers.styles)) styleResolvers = resolvers.styles
else styleResolvers = [resolvers.styles]
return {
...component,
...callResolvers(component, baseResolvers.map(enhancedResolve)),
style: {
...component.style,
...callResolvers(component, styleResolvers.map(enhancedResolve)),
},
}
}
const component = {
type: 'div',
style: {
height: 250,
fontSize: 14,
fontWeight: 'bold',
textAlign: 'center',
},
children: [
{
type: 'input',
inputType: 'email',
placeholder: 'Enter your email',
style: {
border: '1px solid magenta',
},
},
],
}
const result = start(component, {
resolvers: {
base: [resolveTimestampInjection, resolveChildren],
styles: [resolveStyles],
},
})
And we still get an object back with the expected results!
{
"type": "div",
"style": {
"height": 300,
"fontSize": 14,
"fontWeight": "bold",
"textAlign": "center"
},
"children": [
{
"type": "input",
"inputType": "email",
"placeholder": "Enter your email",
"style": {
"border": "1px solid magenta"
},
"textTransform": "uppercase"
}
],
"time": "2:06:16 PM"
}
Conclusion
And that concludes the end of this post! I hope you found this to be valuable and look out for more in the future!
Find me on medium
Top comments (3)
a bit convoluted to understand. but a good article!
Thanks for sharing!
I have a question, why the cover image is php code? xd