Why TypeScript is a better option than JavaScript when it comes to functional programming?

Posted on

In this article, 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. .

drawing

Life without types in a functional programming codebase #

Please try to focus on a hypothetical situation so that we can highlight the value of static types. Let’s say you’re writing code for an election-related app. You’ve just joined the team, and the app is pretty big. You need to write a new feature, and one of the requirements is to ensure that the app user is eligible to vote in elections. One of the older members of the team pointed out to us that some of the code we need is already implemented in a module named @domain/elections and that we can import it as follows:

import { isEligibleToVote } from "@domain/elections";

Importing is a great place to start, and we appreciate any help from or colleague. It’s time to do some work. 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 to pass to it:

isEligibleToVote(????);

We are not afraid to read someone else’s 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 years old
  • The person must be a citizen
  • To be a citizen, the person must be born in the country or naturalized

We need to start reverse engineering our brains to be able to decode the previous code. I was almost sure that isEligibleToVote is a function, but now I have doubts because I don’t see the function keyword or arrow functions (=>) in his statement:

const isEligibleToVote = both(isOver18, isCitizen);

To be able to know what it is, we must examine what is the both doing function. I can see that both take two arguments f and g and I can see that they work because they are invoked f(arg) and g(arg). The both function returns a function arg => f(arg) && g(arg) which takes a named argument args and its form is totally unknown to us at this stage:

const both = (f, g) => arg => f(arg) && g(arg);

We can now return to isEligibleToVote function and try reviewing again to see if we can find anything 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 then isEligibleToVote does something similar to the following:

const isEligibleToVote = arg => isOver18(arg) && isCitizen(arg);

Still need to know what is the argument arg. We can examine the isOver18 and isCitizen functions to find some details.

const isOver18 = person => person.age >= 18;

This information is instrumental. Now we know that isOver18 expects a named argument person and it is an object with a property named age we can also guess by the comparison person.age >= 18 this age is a number.

Let’s take a look at the isCitizen also work:

const isCitizen = either(wasBornInCountry, wasNaturalized);

We’re out of luck here and we have to look into 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 wasBornInCountry and wasNaturalized expect a named argument person and now we have discovered new properties:

  • The birthCountry property appears to be a string
  • The naturalizationDate property appears to be date or null

The either the function passes 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 we can use the isElegibleToVote function can be used as follows:

isEligibleToVote({
    age: 27,
    birthCountry: "Ireland",
    naturalizationDate: null
});

We could overcome some of these issues by using documentation such as JSDoc. However, this means more work and the documentation can quickly become outdated.

TypeScript can help validate that our JSDoc annotations are up to date with our code base. However, if we’re going to do it, why not adopt TypeScript in the first place?

Life with Types in a Functional Programming Codebase #

Now that we know how difficult it is to work in a functional programming codebase without types, let’s take a look at what it’s like to work in a functional programming codebase with static types. We are going to take the same starting point, we joined a company, and one of our colleagues told us the @domain/elections module. However, this time we are in a parallel universe and the codebase is statically typed.

import { isEligibleToVote } from "@domain/elections";

We don’t know if isEligibleToVote is function. However, this time we can do more than guess. 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 the Person type with their types:

That’s it! No reflection 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 may take a little extra type, but the benefits will undoubtedly pay off. Our code will be less error prone, it will be self-documenting, and our team members will be much more productive because they will spend less time trying to figure out pre-existing code.

The universal UX principle Don’t make me think can also make great improvements to our code. Remember that at the end of the day, we spend a lot more time reading than writing code.

About Types in Functional Programming Languages #

Functional programming languages ​​do not need to be statically typed. However, functional programming languages ​​tend to be statically typed. According to Wikipedia, this trend 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 compile time and risking false positive errors, as opposed to the untyped lambda calculus, which accepts all valid programs. at compile time and risk of false negative errors, used in Lisp and its variants (such as Scheme), although they reject all invalid programs at run time, when there is sufficient information not to reject valid programs. Using algebraic data types makes it easier to manipulate complex data structures; the presence of strong compile-time type checking makes programs more reliable in the absence of other reliability techniques such as test-driven development, while type inference frees the programmer from the need to manually declare types to the compiler in most cases.

Consider an object-oriented implementation of the isEligibleToVote functionality 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();
    }
}

Understanding how the preceding code should be invoked is not a trivial task:

import { Person } from "@domain/elections";

new Person("Ireland", 27, null).isEligibleToVote();

Again, without types, we are forced to dig into implementation details.

constructor(birthCountry, age, naturalizationDate) {
    this._birthCountry = birthCountry;
    this._age = age;
    this._naturalizationDate = naturalizationDate;
}

When we use static types, things get 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 generally harder to reverse engineer than object oriented programming. Maybe it’s due to my object-oriented experience. However, whatever the reason, I’m sure of one thing: Types really make my life easier, and their benefits are even more apparent when I’m working on a functional programming codebase.

Summary #

Static types are a valuable source of information. Since we spend much more time reading code than writing it, we need to optimize our workflow to be more efficient reading code than writing it. Types can help us remove a large amount of cognitive effort so that we can focus on the business problem we are trying to solve.

While all of this is true in object-oriented programming codebases, the benefits are even more apparent in functional programming codebases, and that’s exactly why I like to argue that TypeScript is a better option than JavaScript in subject of functional programming. What do you think?

If you enjoyed this article and are interested in Functional Programming or TypeScript, please check out my upcoming book Hands-on functional programming with TypeScript

20

Glory

20

Glory

Leave a Reply

Your email address will not be published.