DEV Community

Edward Huang
Edward Huang

Posted on • Originally published at on

5 Principles of Writing Clean Functional Code

man in black and white jacket wearing black hat standing beside brown concrete building during daytime

The functional programming guru is raving about how adopting functional programming can make developers much more productive.

Yet, many companies don’t see their software engineers’ productivity increase when they adopt functional programming languages.

One reason is that they write functional programming like imperative programming.

Writing functions imperatively can make code harder to read.

In this article, I want to share five principles and best practices for writing clean, functional code.

These are not hard rules everyone must obey, but these five principles can help increase your team's productivity.

1. Stop Using Pattern Matching for Everything

Pattern matching is an overly general tool; many other tools are more specific to solve the problem.

Because of its verbose and repetitive syntax, it is easy to make errors that the type checker won’t catch.

Often, writing pattern matching requires recursion, and recursion is always harder to understand than a regular higher-order function.

Pattern matching is very versatile and flexible. Pattern matching can create every programming logic through pattern matching.

For instance, finding a consecutive duplicate in a list of elements can be done in this way:

// Standard recursive. 
def compressRecursive[A](ls: List[A]): List[A] = 
  ls match {  
    case Nil => Nil  
    case h :: tail => h :: compressRecursive(tail.dropWhile(_ == h)) 
Enter fullscreen mode Exit fullscreen mode

However, it would be better to leverage the declarative nature of functional programming.

We can write the above example with foldLeft, which is more declarative:

def compressFunctional[A](ls: List[A]): List[A] =  ls.foldRight(List[A()) { (h, r) => 
       if (r.isEmpty || r.head != h) h :: r   
       else r  
Enter fullscreen mode Exit fullscreen mode

In addition, pattern matching often tries to reinvent the wheel. For example, a lot of codebase uses pattern matching on an option:

someOpt match { 
   case Some(e) =>  
   case None => 
Enter fullscreen mode Exit fullscreen mode

These functions can be much easier to understand with getOrElse.

Lastly, I often see people replacing if/else statement with pattern matching because the programming language supports it:

val (decimal, roman) = number match {   
  case x if x > 1000 => (1000, "M")   
  case x if x > 900 => (900, "CM")   
  case x if x > 500 => (500, "D")   
  case x if x > 400 => (400, "CD")   
  case x if x > 100 => (100, "C")   
  case x if x > 90 => (90, "XC")   
  case x if x > 50 => (50, "L")   
  case x if x > 40 => (40, "XL")   
  case x if x > 10 => (10, "X")   
  case x if x > 9 => (9, "IX")   
  case x if x > 5 => (5, "V")   
  case x if x > 4 => (4, "IV")   
  case _ => (1, "I")  
Enter fullscreen mode Exit fullscreen mode

You can see that there is a lot of repetition here. Moreover, you always need to do a default catch-all expression at the end.

What if there is no catch-all?

What you can do with this instead is to put the above value into a list and match it against the list:

val conversions = 
    (1000, "M"), 
    (900, "CM"), 
    (500, "D"), 
    (400, "CD"), 
    (100, "C"), 
    (90, "XC"), 
    (50, "L"), 
    (40, "XL"), 
    (10, "X"), 
    (9, "IX"), 
    (5, "V"), 
    (4, "IV"), 
    (1, "I")

conversions dropWhile (_._1 > number)).head
Enter fullscreen mode Exit fullscreen mode

Remember that in functional programming, everything is a value. Putting things into value and working with them as a value is the greatest strength in functional programming. In imperative programming, you must use pattern matching and case statements to do if/else.

2. Stop Using a Callback Function For Everything

A callback function is very flexible if you want to write a quick prototype. However, too many callback parameters in a function is confusing.

I've seen a codebase in one of our payment systems such as below:

def authorize[A,E](
  authFn: (Int, String) => String, 
  analyticsFn: (String) => Future[Unit],
  deserialized: (String => A)): Future[E]
Enter fullscreen mode Exit fullscreen mode

A new developer will not understand what authorize does.


Because this function is trying to do too many things, taking the operation too far by abstracting everything.

You end up with a muddy function that does everything when you abstract everything.

Don’t think too much about "what if."

It violates the Single Responsibility principle.

Instead of making it a callback function, make it a real function and use them inside the authorize function.

def authorize[E](in: String): Future[E] = 
  for { 
    serialized <- desrialized[A](in) 
    resp <- callAuth(serialized)  
    _ <- callAnalytics(in)
  } yield (resp)

def deserialize[A](in: String) : Future[A] = ???

def callAnalytics(in: String): Future[Unit] = ???
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: Avoid using lambda functions unless it is necessarary and requires it.

3. Move Your Exception to Compile Time As Much As Possible

Compile time is safe and predictable. You don’t need to catch them in the production environment.

What is an exception, after all?

The exception should convey information to the user about program failure, and the compiler can catch most of them with useful data.

The exception that you often catch in the production environment is the runtime exception.

A runtime exception is hard to catch because it is unpredictable.

Functional programming created classes such as Either and Option to handle exceptions during compilation time, so you don’t have to deal with complicated bugs in runtime.

Is this value not required? Force the caller to handle them by putting an Option type.

Are you calling an external service through a network call? Use Future, TaskIO, or IO to let the caller know it is unsafe.

Is the function that you are calling causing some error? Do you want to know what that error is? Inform the caller of the function definition and force them to handle the exception with Either type.

Using types for exception helps you track where the exception is being called - you don't have to know where the exception is being thrown during runtime because you can easily trace the exception through the function types. Having these effects types will force the caller to handle them.

4. Don't Try to Eliminate the Intermediate Variable

The human brain understands intermediate variables better. Let me tell you an analogy.

Imagine reading an article without any periods. It will be hard to understand the point. You may need to re-read the article a few times to understand the content.

Getting the message across will take a lot of work.

A one-liner without intermediate variables will make your code harder to understand. Other engineers need to make additional effort to visualize that intermediate variable.

They will need to pause in the middle of that . function and store the result in their brain to continue to the next . function.

Instead of letting their brain work really hard to store these imaginary intermediate variables when reading your code, why don't you help them process it easier by storing the intermediate variables IN the code?

The code below is from Stack Overflow:

def foo(arguments: Seq[(String, String)],
  merges: Seq[(String, String)]) = {  
  val metadata: Seq[Attribute] = 
    (arguments ++ merges)
      .map {    
        case (key, value) =>      
          Attribute(None, key, 
            Text(" ") ), Null)  
  var result: MetaData = Null  
  for(m <- metadata) result = result.append( m )  
Enter fullscreen mode Exit fullscreen mode

They don't know how to eliminate the var, and if we eliminate the var, it will look like this:

def foo(arguments: Seq[(String, String)], merges: Seq[(String, String)]) = (arguments ++ merges).groupBy(_._1).map {  
    case (key, value) => 
      Attribute(None, key, Text(" ")), Null) }.fold(Null)((soFar, attr) => soFar append attr)
Enter fullscreen mode Exit fullscreen mode

This is great and very functional, but it hurts my eyes.

Fortunately, we can still retain the metadata and the pureness by creating an intermediate variable:

type PairSeq = Seq[(String, String)]

def combineText(text: PairSeq): Text = 
  Text(" "))

def foo(arguments: PairSeq, merges: PairSeq) = { 
  val metadata = (arguments ++ merges).groupBy(_._1).map{
    case (key, value) =>    
      Attribute(None, key, combineText(value), Null)  
  metadata.fold(Null)((xs, attr) => xs append attr)
Enter fullscreen mode Exit fullscreen mode

Although a one-liner feels good to write, an intermediate variable will save your future self time and help you onboard your new team member faster.

5. Isolate Your I/O

The imperative way of writing functions doesn't care about I/O operations since they don't return based on type.

However, writing functional code, one will be forced to think about I/O operations because the type system enforces I/O operations.

Isolating your IO helps make your codebase easily readable. Anyone who jumps into the codebase can easily identify that any type wrapped in IO is where to look for indeterministic operations.

Keeping your funIO in a small section of the code and your function pure can help you a lot.

Pure functions are easier to reason about - reorder, refactor, parallelize, type check, share data between, and test.

We often see a function having multiple IO calls, either logging or println between the calls.

For instance, we have a function for calculating the division of an element:

def div(x: Int, y: Int) : Unit =  
  if(y == 0) { 
   println("Cannot divide by zero here.") 
  } else {        
    val res = x/y println(res) 
Enter fullscreen mode Exit fullscreen mode

We can separate the IO so that it is purer by moving the println to the end:

def div(x:Int, y: Int): Either[IllegalArgument, Int] = 
  if(y == 0) 
    Left(new IllegaleArgumentException("Cannot divide by 0"))
  else { 
    x / y 

def printResult(x: Int, y:Int) = div(x, y).foreach(println)
Enter fullscreen mode Exit fullscreen mode

Whenever you see an IO operation, think twice and see if you can reorder and refactor your function and push the IO as far into the end of the world (link).


These are not hard rules we need to obey to write readable, functional code. However, with these rules, you can increase the maintainability and readability of your functional codebase.

Let's recap the five principles:

  1. Stop using Pattern Matching for everything. Determine if any higher-order functions in the standard library can help you solve the problem. Use pattern matching as a last resort in your tool belt.

  2. Stop using a callback function for everything.

  3. Move your exception to compile time as often as possible because compile-time exceptions are much easier to reason than runtime exceptions.

  4. Don't try to eliminate an intermediate variable.

  5. Isolate your IO.

What other principles do you use that help the team write clean, functional code? Comment them down below!

💡 Want more actionable advice about Software engineering?

I’m Edward. I started writing as a Software Engineer at Disney Streaming Service, trying to document my learnings as I step into a Senior role. I write about functional programming, Scala, distributed systems, and careers-development.

Subscribe to the FREE newsletter to get actionable advice every week and topics about Scala, Functional Programming, and Distributed Systems:

Top comments (0)