The try/catch and if/else hell

As a functional programming library, tiinvo can help a lot for clean code

2019-09-03

If your code is filled with if/else or try/catch statements maybe it could be time to clean it up.

One of the most effective ways to get a cleaner code is to use the functional programming paradigm.

The benefits of using functions for conditional flows are many and the greatest one is the possibility to reuse conditions all over your codebase making it D.R.Y. (aka the dont repeat yourself).

There are dozens of libraries written both in JavaScript or TypeScript for functional programming and this time we will give a look a tiinvo.

You can install tiinvo with both npm or yarn.

npm i tiinvo
# or if you use yarn
yarn add tiinvo

Handling try/catch

Try catch are used to prevent your code crashing when an uncaught Error is thrown.

A normal try/catch expression usually looks like this:

function isString(input: unknown): input is string {
  return typeof input === 'string';
}

function thisWillThrow(input: unknown): string | never {
  if (!isString(input)) {
    throw new TypeError('input must be a string');
  }
  
  return input;
}

export async function handler(context) {
  try {
    const param = thisWillThrow(context.req.body.foo);
    
    return {
      status: 200,
      body: {
        param,
      },
    };
  } catch (error) {
    return {
      status: 500,
      body {
        error,
      },
    };
  }
}

Too many curly braces indeed.

Using tiinvo, you can first avoid the try/catch construct, invoking your function safely, then you can decide what to do with your Result.

Let's rewrite it

import { TryCatch } from 'tiinvo';

function createResponse(status, body) {
  return {
    body,
    status,
  }
}

function isString(input: unknown): input is string {
  return typeof input === 'string';
}

function thisWillThrow(input: unknown): string | never {
  if (!isString(input)) {
    throw new TypeError('input must be a string');
  }
  
  return input;
}

export async function handler(context) {
  return TryCatch(thisWillThrow, context.req.body.foo)
    .mapOrElse(
      error => createResponse(500, { error }),
      param => createResponse(200, { param }),
    );
}

Cleaner. Note that TryCatch can call your function with the needed signature arguments.

You can also handle async functions with TryCatchAsync.

import { TryCatchAsync } from 'tiinvo';

async function catchy() {
  throw new Error('💥💥💥');
}

export async function maybeExplodes() {
  return (await TryCatchAsync(catchy)).mapOrElse(
    () => 'exploded',
    () => 'wow is safe',
  );
}

Handling if/else statements with Either

The if/else statements are used to control the execution flow of your code. There are different constructs for controlling code flow, but the most used is the Either construct, which represents a value that could be considered falsy (left) or truthy (right).

To make an example, give a look at the code below.

// file a.ts
// could possibly throw, forcing us to use a try/catch
export function doStuff(num: number): number | never {
  // if statement
  if (num % 2 !== 0) {
    throw new Error('number is not even');
  }
  
  return num;
}

// file b.ts
import { doStuff } from './a.ts';

doStuff(2);
doStuff(4);
// throws, stops execution. To avoid it, we should use a try/catch, but not today 
doStuff(5); 
doStuff(6);

Rewriting the example above using tiinvo will look like this

// file a.ts

import { Either, Err, Left, Ok, Right } from 'tiinvo';

// yep I know, a really silly example
function isEven(num: number): Either<number, number> {
  return num % 2 === 0 ? Right(num) : Left(num);
}

export function doStuff(num: number): Result<number, Error> {
  return isEven(num)
    .fold(
      () => Err('number is not even'),
      num => Ok(num),
    )
}

// file b.ts
import { doStuff } from './b.ts';

doStuff(2); // returns Ok(2)
doStuff(4); // returns Ok(4)
doStuff(5); // returns Err() so we can handle without a try catch and without blocking execution
doStuff(6); // returns Ok(6)

handling possible result from an operation

Error handling is one of the worst parts of programming. You have to catch them all® using the try/catch syntax, which sometimes is a lot messy.

To avoid this, you can use the Result data type, which handles both an Err if something did go wrong or an Ok<T> if something went correctly.

// a.ts
export function isEven(num: number): number | null {
  return num % 2 === 0 ? num : null;
}

// b.ts
import { isEven } from './a.ts';

export async function getCount() {
  try {
    const result = await fetch('/my/url');
    const json = await result.json();
    
    if (isEven(json.count)) {
      return { count: json.count };
    } else {
      return { count: 0 };
    }
  } catch (error) {
    return { count: 0 };
  }
}

You can rewrite like this

// a.ts
import { Some } from 'tiinvo';

export function isEven(num: number): Some<number> {
  return Some(
    num % 2 === 0 ? num : null
  )
}

// b.ts
import { TryCatchAsync } from 'tiinvo';
import { isEven } from './a.ts';

function count(num: number) {
  return { count: num };
}

export async function getCount() {
  const result = await TryCatchAsync(fetch, '/my/url');
  const json = await TryCatchAsync(result.json);
  
  return json.mapOrElse(
    () => count(0),
    num => isEven(num).mapOr(count(0), val => count(val))
  );
}

using Option for optional values

Option is similar to the Maybe monad, but is used to express a value that is optional, therefore it's name. The implementation of Option is taken from rust language std::option. Unlike Maybe, which treats every falsy value like Nothing, Option flatterns undefined, null, NaN or invalid Dates as None.

import { Option } from 'tiinvo';

export interface IUser {
    name?: string;
    surname?: string;
    email?: string;
}

export type IMappedUser = Record<IUser, Option<string>>;

// maps IUser to IMappedUser
export function map(user: IUser): IMappedUser {
    return Object.keys(user).reduce(
        (collector, key) => (
            {
                ...collector,
                [key]: Option(user[key])
            }
        ),
        {}
    );
}

export function isValid(mappedUser: IMappedUser): boolean {
    return mappedUser.email
        .and(mappedUser.name)
        .and(mappedUser.surname)
        .isSome();
}

isValid(
    map({ email: 'john.doe@gmail.com' })
); // false

isValid(
    map({ email: 'john.doe@gmail.com', name: 'john' })
); // false

isValid(
    map({ email: 'john.doe@gmail.com', name: 'john', surname: 'doe' })
); // true