Why TypeScript is a better option than JavaScript when it comes to functional programming?
Remo H. Jansen
Posted on January 30, 2019
In this post, I would like to discuss the importance of static types in functional programming languages and why TypeScript is a better option than JavaScript when it comes to functional programming due to the lack of a static type system in JavaScript.
Life without types in a functional programming code base
Please try to put your mind on a hypothetical situation so we can showcase the value of static types. Let's imagine that you are writing some code for an elections-related application. You just joined the team, and the application is quite big. You need to write a new feature, and one of the requirements is to ensure that the user of the application is eligible to vote in the elections. One of the older members of the team has pointed out to us that some of the code that we need is already implemented in a module named @domain/elections
and that we can import it as follows:
import { isEligibleToVote } from "@domain/elections";
The import is a great starting point, and We feel grateful for the help provided by or workmate. It is time to get some work done. However, we have a problem. We don't know how to use isEligibleToVote
. If we try to guess the type of isEligibleToVote
by its name, we could assume that it is most likely a function, but we don't know what arguments should be provided to it:
isEligibleToVote(????);
We are not afraid about reading someoneelses code do we open the source code of the source code of the @domain/elections
module and we encounter the following:
const either = (f, g) => arg => f(arg) || g(arg);
const both = (f, g) => arg => f(arg) && g(arg);
const OUR_COUNTRY = "Ireland";
const wasBornInCountry = person => person.birthCountry === OUR_COUNTRY;
const wasNaturalized = person => Boolean(person.naturalizationDate);
const isOver18 = person => person.age >= 18;
const isCitizen = either(wasBornInCountry, wasNaturalized);
export const isEligibleToVote = both(isOver18, isCitizen);
The preceding code snippet uses a functional programming style. The isEligibleToVote
performs a series of checks:
- The person must be over 10
- The person must be a citizen
- To be a citizen, the person must be born in the country or naturalized
We need to start doing some reverse engineering in our brain to be able to decode the preceding code. I was almost sure that isEligibleToVote
is a function, but now I have some doubts because I don't see the function
keyword or arrow functions (=>
) in its declaration:
const isEligibleToVote = both(isOver18, isCitizen);
TO be able to know what is it we need to examine what is the both
function doing. I can see that both takes two arguments f
and g
and I can see that they are function because they are invoked f(arg)
and g(arg)
. The both
function returns a function arg => f(arg) && g(arg)
that takes an argument named args
and its shape is totally unknown for us at this point:
const both = (f, g) => arg => f(arg) && g(arg);
Now we can return to the isEligibleToVote
function and try to examine again to see if we can find something new. We now know that isEligibleToVote
is the function returned by the both
function arg => f(arg) && g(arg)
and we also know that f
is isOver18
and g
is isCitizen
so isEligibleToVote
is doing something similar to the following:
const isEligibleToVote = arg => isOver18(arg) && isCitizen(arg);
We still need to find out what is the argument arg
. We can examine the isOver18
and isCitizen
functions to find some details.
const isOver18 = person => person.age >= 18;
This piece of information is instrumental. Now we know that isOver18
expects an argument named person
and that it is an object with a property named age
we can also guess by the comparison person.age >= 18
that age
is a number.
Lets take a look to the isCitizen
function as well:
const isCitizen = either(wasBornInCountry, wasNaturalized);
We our out of luck here and we need to examine the either
, wasBornInCountry
and wasNaturalized
functions:
const either = (f, g) => arg => f(arg) || g(arg);
const OUR_COUNTRY = "Ireland";
const wasBornInCountry = person => person.birthCountry === OUR_COUNTRY;
const wasNaturalized = person => Boolean(person.naturalizationDate);
Both the wasBornInCountry
and wasNaturalized
expect an argument named person
and now we have discovered new properties:
- The
birthCountry
property seems to be a string - The
naturalizationDate
property seems to be date or null
The either
function pass an argument to both wasBornInCountry
and wasNaturalized
which means that arg
must be a person. It took a lot of cognitive effort, and we feel tired but now we know that we can use the isElegibleToVote
function can be used as follows:
isEligibleToVote({
age: 27,
birthCountry: "Ireland",
naturalizationDate: null
});
We could overcome some of these problems using documentation such as JSDoc. However, that means more work and the documentation can get outdated quickly.
TypeScript can help to validate our JSDoc annotations are up to date with our code base. However, if we are going to do that, why not adopt TypeScript in the first place?
Life with types in a functional programming code base
Now that we know how difficult is to work in a functional programming code base without types we are going to take a look to how it feels like to work on a functional programming code base with static types. We are going to go back to the same starting point, we have joined a company, and one of our workmates has pointed us to the @domain/elections
module. However, this time we are in a parallel universe and the code base is statically typed.
import { isEligibleToVote } from "@domain/elections";
We don't know if isEligibleToVote
is function. However, this time we can do much more than guessing. We can use our IDE to hover over the isEligibleToVote
variable to confirm that it is a function:
We can then try to invoke the isEligibleToVote
function, and our IDE will let us know that we need to pass an object of type Person
as an argument:
If we try to pass an object literal our IDE will show as all the properties and of the Person
type together with their types:
That's it! No thinking or documentation required! All thanks to the TypeScript type system.
The following code snippet contains the type-safe version of the @domain/elections
module:
interface Person {
birthCountry: string;
naturalizationDate: Date | null;
age: number;
}
const either = <T1>(
f: (a: T1) => boolean,
g: (a: T1) => boolean
) => (arg: T1) => f(arg) || g(arg);
const both = <T1>(
f: (a: T1) => boolean,
g: (a: T1) => boolean
) => (arg: T1) => f(arg) && g(arg);
const OUR_COUNTRY = "Ireland";
const wasBornInCountry = (person: Person) => person.birthCountry === OUR_COUNTRY;
const wasNaturalized = (person: Person) => Boolean(person.naturalizationDate);
const isOver18 = (person: Person) => person.age >= 18;
const isCitizen = either(wasBornInCountry, wasNaturalized);
export const isEligibleToVote = both(isOver18, isCitizen);
Adding type annotations can take a little bit of additional type, but the benefits will undoubtedly pay off. Our code will be less prone to errors, it will be self-documented, and our team members will be much more productive because they will spend less time trying to understand the pre-existing code.
The universal UX principle Don't Make Me Think can also bring great improvements to our code. Remember that at the end of the day we spend much more time reading than writing code.
About types in functional programming languages
Functional programming languages don't have to be statically typed. However, functional programming languages tend to be statically typed. According to Wikipedia, this tendency has been rinsing since the 1970s:
Since the development of Hindley–Milner type inference in the 1970s, functional programming languages have tended to use typed lambda calculus, rejecting all invalid programs at compilation time and risking false positive errors, as opposed to the untyped lambda calculus, that accepts all valid programs at compilation time and risks false negative errors, used in Lisp and its variants (such as Scheme), though they reject all invalid programs at runtime, when the information is enough to not reject valid programs. The use of algebraic datatypes makes manipulation of complex data structures convenient; the presence of strong compile-time type checking makes programs more reliable in absence of other reliability techniques like test-driven development, while type inference frees the programmer from the need to manually declare types to the compiler in most cases.
Let's consider an object-oriented implementation of the isEligibleToVote
feature without types:
const OUR_COUNTRY = "Ireland";
export class Person {
constructor(birthCountry, age, naturalizationDate) {
this._birthCountry = birthCountry;
this._age = age;
this._naturalizationDate = naturalizationDate;
}
_wasBornInCountry() {
return this._birthCountry === OUR_COUNTRY;
}
_wasNaturalized() {
return Boolean(this._naturalizationDate);
}
_isOver18() {
return this._age >= 18;
}
_isCitizen() {
return this._wasBornInCountry() || this._wasNaturalized();
}
isEligibleToVote() {
return this._isOver18() && this._isCitizen();
}
}
Figuring this out how the preceding code should be invoked is not a trivial task:
import { Person } from "@domain/elections";
new Person("Ireland", 27, null).isEligibleToVote();
Once more, without types, we are forced to take a look at the implementation details.
constructor(birthCountry, age, naturalizationDate) {
this._birthCountry = birthCountry;
this._age = age;
this._naturalizationDate = naturalizationDate;
}
When we use static types things become easier:
const OUR_COUNTRY = "Ireland";
class Person {
private readonly _birthCountry: string;
private readonly _naturalizationDate: Date | null;
private readonly _age: number;
public constructor(
birthCountry: string,
age: number,
naturalizationDate: Date | null
) {
this._birthCountry = birthCountry;
this._age = age;
this._naturalizationDate = naturalizationDate;
}
private _wasBornInCountry() {
return this._birthCountry === OUR_COUNTRY;
}
private _wasNaturalized() {
return Boolean(this._naturalizationDate);
}
private _isOver18() {
return this._age >= 18;
}
private _isCitizen() {
return this._wasBornInCountry() || this._wasNaturalized();
}
public isEligibleToVote() {
return this._isOver18() && this._isCitizen();
}
}
The constructor tells us how many arguments are needed and the expected types of each of the arguments:
public constructor(
birthCountry: string,
age: number,
naturalizationDate: Date | null
) {
this._birthCountry = birthCountry;
this._age = age;
this._naturalizationDate = naturalizationDate;
}
I personally think that functional programming is usually harder to reverse-engineering than object-oriented programming. Maybe this is due to my object-oriented background. However, whatever the reason I'm sure about one thing: Types really make my life easier, and their benefits are even more noticeable when I'm working on a functional programming code base.
Summary
Static types are a valuable source of information. Since we spend much more time reading code than writing code, we should optimize our workflow so we can be more efficient reading code rather than more efficient writing code. Types can help us to remove a great amount of cognitive effort so we can focus on the business problem that we are trying to solve.
While all of this is true in object-oriented programming code bases the benefits are even more noticeable in functional programming code bases and this exactly why I like to argue that TypeScript is a better option than JavaScript when it comes to functional programming. What do you think?
If you have enjoyed this post and you are interested in Functional Programming or TypeScript, please check out my upcoming book Hands-On Functional Programming with TypeScript
Posted on January 30, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024