Towards a safer JavaScript
Richard Torruellas
Posted on January 12, 2020
As programs become large, their complexity also grows. Bugs become harder to track down, mistakes easier to make.
What if we could make small parts of our application more predictable? What if we could start to create fewer places where bugs originate?
By adopting few basic principals, we can start having more predictable outcomes.
- Everything must return a value
- A function called with the same arguments will return the same value
These constraints were popularized by something called Functional Programming.
Another property of Functional Programming is that everything is a function (surprise). Let's understand what these three constraints give us.
(these examples are for understanding, not to copy and paste into your application)
Everything must return a value?
// application.js
import { setPersonsName } from "./person";
console.log(setPersonsName({ name: undefined, age: undefined }, "bob"));
//> undefined
Above, someone is breaking our rules. setPersonsName
returns no value. In a small and simple program, this might be "predictable" because you can easily look up what setPersonsName
from ./person
does (maybe). Imagine this application grows over the years, and to understand what setPersonsName
does, we would have to potentially step through many lines of indirection and irrelevant code. Maybe not, maybe your application is an outlier and perfect. By not returning a value, we don't really know what setPersonsName
or is used for. It could be setting values somewhere or it could do nothing.
Instead, we could choose a constraint that everything must return a value. Maybe it can set the name property on a person object?
// application.js
import { setPersonsName } from "./person";
console.log(setPersonsName("bob"));
//> {name: "bob", age: undefined}
Already we can see some benefit. We could write some tests that test the value of setPersonsName
!
A function called with the same arguments will return the same value
import { setPersonsName } from "./person";
console.log(setPersonsName("bob"));
//> {name: "bob", age: undefined}
console.log(setPersonsName("bob"));
//> {name: "bob", age: 42}
Above, we can see setPersonsName
now breaks our second rule of "A function called with the same arguments will return the same value". Hey, at least we have something being returned, right? But why are we now seeing the age
property set to 42
? We didn't change it! Who did? Who knows?
This is a common place of bugs in much of the software that I've maintained over the years. Not understanding where and how things changed over time. Let's not do that if we can.
Everything must return a value + A function called with the same arguments will return the same value
So how might we go about writing a better API for our teammates? What if instead of modifying some data outside of our control, we could provide setPersonsName
with a copy of the data that needs to be modified? We could then a copy of the value we provided.
setPersonsName({ name: undefined, age: undefined }, "bob");
//> {name: "bob", age: undefined}
setPersonsName({ name: undefined, age: undefined }, "bob");
//> {name: "bob", age: undefined}
That might look something like
function setPersonsName(person, name) {
return { ...person, name };
}
Everything is a function
If we apply the two above constraints with a third, everything must be a function, we are getting close to Functional Programming. The rest of what you learn about Functional Programming are abstractions to make these constraints easier to deal with, while maintaining these constraints.
- everything must be a function
- everything has a value
- everything must return the same value given the same arguments
Abstractions
Sharing data over time with partial applications
Maybe at the start of a program you want to set some state, then later add to that state for a new value. One way we can do this is with partial applications.
function personData(person, fn) {
let _person = { name: undefined, age: undefined, ...person };
return fn(_person);
}
const a = personData(
{
name: undefined,
age: undefined
},
person => ({ ...person, age: 23 })
);
// a = {name: undefined, age: 23}
const person = personData(a, person => ({ ...person, name: "bob" }));
// person = {name: "bob", age: 23}
This might be a little too much work, so like anything, we can abstract even further. Using a new abstraction, sometimes called "pipe" can help us here. It takes a list of functions and applies the previous functions result and calls it with that.
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const person = pipe(
person => ({ ...person, age: 23 }),
person => ({ ...person, name: "bob" })
);
// person() = {name: "bob", age: 23}
Pipe expanded for readability.
function pipe(...listOfFunctions) {
return function(dataTheFunctionsOperateOn) {
return listOfFunctions.reduce(
(data, functionFromList) => functionFromList(data),
dataTheFunctionsOperateOn
);
};
}
If we reintroduce the concept of partial applications, we can even come up with something a little nicer to look at.
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const partiallyApply = (f, a) => b => f(a, b);
const setNameOnObject = (name, x) => ({ ...x, name });
const setAgeOnObject = (age, x) => ({ ...x, age });
const person = pipe(
partiallyApply(setNameOnObject, "bob"),
partiallyApply(setAgeOnObject, 24)
);
// person() = {name: "bob", age: 23}
In the future, there will be a Pipe Operator built into the language, which will make this type of programming even nicer. Link to MDN about the proposed Pipe Operator.
const partiallyApply = (f, a) => b => f(a, b);
const setNameOnObject = (name, x) => ({ ...x, name });
const setAgeOnObject = (age, x) => ({ ...x, age });
const person =
partiallyApply(setNameOnObject, "bob") |> partiallyApply(setAgeOnObject, 24);
// person = {name: "bob", age: 23}
A New Data Type
So maybe we want more ways to set some data in a safer way that we can operate on. Some values in JavaScript we can map
on. For example, Arrays have a map method. The map method allows us to call a function on each value, then returns a new array leaving the previous array unmodified (mutated). What if we could have more values in JavaScript that had map methods?
const Value = value => ({
value,
map: apply => Value(apply(value)),
flatMap: apply => apply(value)
});
const person = Value({ name: undefined, age: undefined });
const newPerson = person.map(o => setNameOnObject("bob", o));
const newPersonTwo = newPerson.map(o => setAgeOnObject(24, o));
console.log(person.value, newPersonTwo.value);
// person.value = Object { name: undefined, age: undefined }
// newPersonTwo.value = Object { name: "bob", age: 24 }
But what if we take some of the patterns and abstractions we learned above to this new Value type? We can start to compose even more.
const person = Value({ name: undefined, age: undefined }).map(
pipe(
partiallyApply(setNameOnObject, "bob"),
partiallyApply(setAgeOnObject, 24)
)
);
When we start to get familiar with new data types, we can refined their usage even more. Maybe you have a different mental model and want to set things a little differently.
const flatten = fns => [].concat.apply([], fns);
const pipe = (...fns) => x => flatten(fns).reduce((y, f) => f(y), x);
const Value = value => ({
value,
map: apply => Value(apply(value)),
flatMap: apply => apply(value)
});
const person = Value({ name: undefined, age: undefined });
const setName = name => p => p.map(o => ({ ...o, name }));
const setAge = age => p => p.map(o => ({ ...o, age }));
const setPropertiesOn = obj => (...fns) => {
return pipe(fns)(obj);
};
const a = pipe(setName("pam"), setAge(55))(person);
const b = setPropertiesOn(person)(setName("pam"), setAge(55));
// a.value = Object { name: "pam", age: 55 }
// b.value = Object { name: "pam", age: 55 }
Maybe you just want to call a function on your new value. Do something "interesting" with it, without needing to create a new Value.
You can do this with flatMap
.
const flatten = fns => [].concat.apply([], fns);
const pipe = (...fns) => x => flatten(fns).reduce((y, f) => f(y), x);
const Value = value => ({
value,
map: apply => Value(apply(value)),
flatMap: apply => apply(value)
});
const toJson = obj => JSON.stringify(obj);
const post = async body =>
fetch("https://httpbin.org/post", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body
});
const getJsonFromResponse = async response => {
const r = await response;
const json = await r.json();
return json;
};
async function createUser(name, age) {
return await Value({ name, age }).flatMap(
pipe(toJson, post, getJsonFromResponse)
);
}
async function handleUserSubmit() {
const user = await createUser("pam", 56);
console.log(JSON.parse(user.data));
// Object { name: "pam", age: 56 }
}
The future
I hope that in the future we have more of these utilities built into the language. Imagine if we had first class support for this new data type and Pipe. We could safely call functions on those values using pipe and a whole no ecosystem of nice utility functions would be published by the Javascript community.
We do have languages that can compile to JavaScript that have some of these nice features built in. For example, ReasonML has syntax for pipe.
Closing
We made a few safe and predictable programs by always returning a value, but never modifying any previous values. We always copy a value and return a new one. We will never have to worry about why something changed over time without us knowing.
We actually overlooked a lot of reasons that make functional programming great. Things like Immutability and Memorization for safety and performance, or things like concurrency. I hope by mentioning them, you will take it upon yourself to research those terms further.
That is all for now. I hope you can use something you learned here and apply them to your work. Thanks for reading!
Helpful links
Posted on January 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.