Practical uses for functional programming in TypeScript (examples from NLP)

As I have been learning about functional programming, I’ve been delighted to see how these seemingly abstract FP contepts can be used to smooth over real-life pain points that I was running into in my code base. As I was understanding what monoids, functors, and monads actually were, I also started to see how they could be used to instantly and safely melt away huge chunks of repetitive and confusing code.

People struggle often struggle to see how these “esoteric” ideas from functional programming could possibly be useful outside of the typical examples that we often see in tutorials:

  • A monoid for string concatenation? Just use +
  • A functor for mapping over arrays? That’s just Array.map()
  • A monad for error handling, IO, or async? Come on bro… don’t overcomplicate things…

But when we really get a handle on these FP concepts, we find that we have some very powerful tools that can help us in many more ways than just mapping over arrays, or handling errors.

Let me explain how I’ve used some functional programming concepts while building pashto-inflector in TypeScript, a library for working with Pashto text, parsing, and phrase generation. Hopefully these use-cases will help you grok the FP concepts, and give you ideas for how you might want to use them as well.

Monoid

A monoid is a type of values that is defined by two things:

  1. a binary operation (something that puts two values together like adding two numbers, joining two strings), and
  2. an identity element (something “empty” that doesn’t change the value), so that you can put any number of them together.

The classic examples

With numbers:

  • the binary operation is adding ( x+yx + y )
  • the identity element is 0

Once we know these things, we can add together any number of numbers.

function sum(numbers: number[]): number {
  return numbers.reduce(
    (acc, n) => (
      // binary operation
      acc + n,
      // identity element
      0
    )
  );
}

console.log(sum([2,4,1]))
// 7

or we could do the same thing with strings:

  • the binary operation is string concatenation
  • the identity element is an empty string ""
function concatAll(strings: string[]): string {
  return strings.reduce(
    (acc, s) => (
      // binary operation
      acc + s,
      // identity element
      ""
    )
  );
}

console.log(concatAll(["fp", "is", "cool"]))
// "fpiscool"

My special use case

In my pashto-inflector library I have a special data structure that I use to represent Pashto text along with latin phonetics. Instead of a plain string of text I have an object that holds both the standard Pashto text, as well as the phonetics in latin letters which contain more information.

type PsString = {
  \** Pashto text *\
  p: string,
  \** Latin phonetics *\
  f: string,
}

Now when I want to join a bunch of these special text units together, I can’t just use the + operator in TypeScript. I need to define a new function that will join these pieces of text together.

function concatPsString(a: PsString, b: PsString): PsString {
  return {
    p: a.p + b.p,
    f: a.f + b.f,
  };
}

We’ve got a binary operation, and so we’re half-way to defining a monad here… we just need the identity element. What can we always add to the PsString without changing it?

const psStringIdElem: PsString = {
  p: "",
  f: "",
} 

Now we can make a similar function that will allow us to concat an arbitrary amount of PsStrings

function concatAllPsStrings(ps: PsString[]): PsString {
  return ps.reduce(
    (acc, s) => (
      // binary operation
      concatPsString(acc, s),
      // identity element
      { p: "", f: "" }
    )
  );
}

console.log(concatAllPsString([
  { p: "کور", f: "kor" }, { p: "ونه", f: "óona" },
]))
// { p: "کورنه", f: "koróona" }

Look familiar? In all these examples, we’re doing the same basic operation. These abstractions allow us to express more and more complicated actions on more complex data structures using the exact same, simple pattern.

Before I knew what I monoid was I wrote a much more convulated, recursive function to try to join these PsStrings together.

If we use the fp-ts library, we can get the concat function of a monoid for free, just by defining the binary operation and the identity element

const monoidPsString: Monoid<PsString> = {
  // binary operation
  concat: concatPsSting,
  // identity element
  empty: { p: "", f: "" },
};

So if you know how to put two items together, and you know what an “empty” item is, you automatically know how to put as many items together as you want!

import { concatAll } from "fp-ts/lib/Monoid";

console.log(monoidPsString(concatAll)([
  { p: "لوست", f: "lwast" }, { p: "ل", f: "ul" }, { p: "ه", f: "a" },
]))
// { p: "لوستله", f: "lwastula" }

Functor

A functor is a pattern that let’s us apply some function to values inside a data structure, without changing the structure.

functor diagram

With a functor we can take a function that operates on a value, and a value in a container. Then with a bunch of FP magic, the function will rip the value out of the container, transform it, and shove it back into the container.

Classic example

One of the classic examples of a functor is applying (mapping) a function to each element in an array

function array

The function is applied to the data inside of the “container” (in this case an array), giving us back the same kind of container with the modified data.

We can implement an array functor in TypeScript like so:

function fmapArray<A, B>(f: (a: A) => B, arr: A[]): B[] {
  const output: B[] = [];
  for (let x of arr) {
    output.push(f(x));
  }
  return output;
}

function add1(x: number): number {
  return x + 1;
}

console.log(fmapArray(add1, [2,3,4]))
// [3,4,5]

But in TypeScript we already have this as Array.map

console.log([2,3,4].map(add1))
// [3,4,5]

My special use case

Earlier a mentioned my special PsString data type for representing Pashto strings and their latin phonetics letters. I also have another data type that I use to hold potential length variations (if they exist):

type SingleOrLengthOpts<A> = A | {
  long: A,
  short: A,
}

So if I have a variable of type SingleOrLengthOpts<PsString> this means that it could either be a plain PsString, or a long and short version of the PsString. Here are some examples:

const examples: SingleOrLengthOpts<PsString>[] = [
  { p: "نوم", f: "noom" },
  {
    long: { p: "لیک", f: "leek" },
    short: { p: "لیکل", f: "leekul" },
  },
];

This is nice, but when I was trying to work with this data type it got a bit tedious trying to check if there were length options or not, and then doing something to it:

function someFunction(x: SingleOrLengthOpts<PsString>): SingleOrLengthOpts<PsString> {
  // ...

  if ("long" in x) {
    return {
      long: doSomethingElse(x.long),
      short: doSomethingElse(x.short),
    };
  } else {
    return doSomethingElse(x),
  }
}

I found myself writing stuff like this over and over again! There must be some way I could automate all this annoying checking and handling the potential length options… 🤔

My weird SingleOrLengthOpts is a particular kind of data structure, or container. And functors let us apply functions to data in containers while preserving the container. Functors to the rescue! Yes, if I could define a functor for SingleOrLengthOpts, then I could just apply any function to the contents of SingleOrLengthOpts, and it would handle all the messy stuff for me!

functor diagram of SingleOrLengthOpts
export function fmapSingleOrLengthOpts<A, B>(
  f: (x: A) => B,
  x: T.SingleOrLengthOpts<A>
): T.SingleOrLengthOpts<B> {
  // if there are length options on the piece of data
  // apply the function to each length option
  if (x && typeof x === "object" && "long" in x) {
    return {
      long: f(x.long),
      short: f(x.short),
    };
  }
  // otherwise just apply the function to the plain data
  return f(x);
}

That little fmap function for SingleOrLengthOpts allows me to melt away all kinds of repetitive and annoying code.

function someFunction(x: SingleOrLengthOpts<PsString>): SingleOrLengthOpts<PsString> {
  // ...

  // GOODBYE repetitive junk 🗑️
  // if ("long" in x) {
  //   return {
  //     long: doSomethingElse(x.long),
  //     short: doSomethingElse(x.short),
  //   };
  // } else {
  //   return doSomethingElse(x),
  // }

  // HELLO functor ! ✨
  return fmapSingleOrLengthOpts(doSomethingElse, x);
}

This was quite helpful, and I cleaned up big chunks of repetitive, error prone code with this one-line abstraction. But I realized I couldn’t use it everywhere

The problem was that sometimes I wanted to apply a different function depending on whether something was “long” or “short”, but my clever functor just blindly applied a function to the data inside the structure. Whenever I wanted a different behaviour depending on the length, I had to go back to my old, tiresome way of manually checking and applying the function to all the lengths.

function someOtherFunction(x: SingleOrLengthOpts<PsString>): SingleOrLengthOpts<PsString> {
  // ...

  if ("long" in x) {
    return {
      long: doSomethingElse(x.long),
      short: doSomethingSpecial(x.short),
    };
  } else {
    return doSomethingElse(x),
  }
}

Could there be a nice FP abstraction that would let me clean up these bits of code? Why yes there is!

Applicative Functor

An applicitive functor let’s us apply a function in a container to some data in a container!

applicative functor diagram

At first I struggled to imagine why I would ever want to do this. Why would I want to put a function in the same kind of container as the data??

But here’s the cool thing… Just like I could have a potential length options for my data, I could have potential length options for my function! So if I wanted to pass in a different function depending on the length of the data, I could do that!

So let’s say I wanted to apply the function doSomethingSpecial whenever I encountered a short version of the text, but doSomethingElse in any other case. Instead of writing the old manual…

function someOtherFunction(x: SingleOrLengthOpts<PsString>): SingleOrLengthOpts<PsString> {
  // ...

  if ("long" in x) {
    return {
      long: doSomethingElse(x.long),
      short: doSomethingSpecial(x.short),
    };
  } else {
    return doSomethingElse(x),
  }
}

We could write something like this

  // ...
  return applicativeFunctorMagic(
    // function in a container!
    {
      long: doSomethingElse,
      short: doSomethingSpecial,
    },
    // data in a container
    x,
  );

All we need to do is write our one function for our applicative functor magic and define how we want to apply our container of function(s) to our container of data.

export function applySingleOrLengthOpts<A, B>(
  f: T.SingleOrLengthOpts<(a: A) => B>,
  a: T.SingleOrLengthOpts<A>
): T.SingleOrLengthOpts<B> {
  if (f && "long" in f) {
    // if there's a long / short version of the function
    // as well as a long / short version of the data
    // apply the appropriate version to the appropriate length
    if (a && typeof a === "object" && "long" in a) {
      return {
        long: fmapSingleOrLengthOpts(f.long, a.long) as B,
        short: fmapSingleOrLengthOpts(f.short, a.short) as B,
      };
    } else {
      // if there's no length options in the data
      // just apply the long version of the function by default
      return fmapSingleOrLengthOpts(f.long, a);
    }
  } else {
    // if it's just a plain function and plain data, apply the function to the data
    return fmapSingleOrLengthOpts(f, a);
  }
}

Once we have all these pieces, we can quickly and cleanly write powerful bits of code that before would take huge amounts of repetition and boilerplate.

For example, let’s say we have a bunch of stems of verbs here. Some are just a single length, and some have length variations:

const stems: SingleOrLengthOpts<PsString>[] = [
  {
    long: { p: "ګرزېږ", f: "gurzeG" },
    short: { p: "ګرز", f: "gurz" },
  },
  { p: "لیک", f: "leek" },
  { p: "وین", f: "ween" },
];

And then we wanted to add endings on all of them, but how we add the endings depends on whether we have a long or a short version of the text.

const withEndings = stems.map(s => applyEnding({
  long: addAccentedEnding,
  short: addUnAccentedEnding,
}, s));

And the applicitave functor will handle all the special checking and applying for all the different cases automatically!

If we were to curry the applyEnding function we can get something even nicer:

const withEndings = stems.map(applyEnding({
  long: addAccentedEnding,
  short: addUnAccentedEnding,
}));

Monad

As for monads… I found a really sweet use case for them while working on the parser in pashto-inflector. I used monads as a way to handle the tokens and error messages being passed around between the parsing functions. But… that will be left for a topic for another day.


Profile picture

Written by Adam Dueck who likes learning about languages human, or digital.

© 2024