Blog
April 10, 2024

Building the same app in Gleam and JavaScript

The goal of this article is to give you a taste of how to create a simple app in Gleam.

Since Gleam’s syntax is different from most other languages, I thought it would be more enjoyable if we compared it with another language as we’re building the app.

I think JavaScript is a good fit because almost all developers are familiar with its syntax.

What are we going to build?

To focus on the language itself, we will build a simple CLI todo app, something everybody is familiar with. Here’s a video of what the app would look like.

We will implement four commands: list to list all todos, add to add a new todo, done to mark multiple todos as done, and clear to delete all todos.

Before continuing, I encourage you to check out the code on GitHub first to get a better idea of how we’re going to build it.

To make explaining things as clear as possible, I will break the article into sections where every two sections show how to implement something in JavaScript and then in Gleam.

Before we start, here are a few things we need to agree on about how todos are stored:

JavaScript: create a new project

In this example, I will be using Node v20 and ESM modules. For this example, I will not use any third-party libraries, but it’s still a good practice to create a new package.json file.

I’m using PNPM as the package manager, but you can use your preferred one. So, to create package.json, I can just run pnpm init and it will create it with some default values.

And then, create todo.mjs in the root directory. That’s it!

Gleam: create a new project

First, make sure you have Gleam installed on your machine. Check out the docs to learn how to do that.

After that, run gleam new gleam_todos. This will create a new project for you.

Next, let’s install the needed dependencies. We will need argv to access the command-line arguments, simplifile to work with files, and gleam_community_ansi to format text with ANSI escape sequences.

To install them, run:

gleam add argv simplifile gleam_community_ansi

JavaScript: get command arguments

Since I’m not going to use any third-party library here, I will just get the arguments using process.argv.

I’ll create a helper function for that called getArgs().

function getArgs() {
  const [, , command, ...args] = process.argv
  return {
    command,
    args
  }
}

Then, I’ll use it in the entry point of the app. Let’s put that in a function called main(). (Note that we don’t have to do that in .mjs, but let’s be consistent with the Gleam version.)

// The file we will store todos in
const FILE_PATH = './my.todo'

async function main() {
  const { command, args } = getArgs()
  switch (command) {
    // Handle the command here
  }
}

function getArgs() {
  // ...
}

main()

Gleam: get command arguments

Open src/gleam_todos.gleam and put the following.

import gleam/io
import argv

// The file we will store todos in
const file_path = "my.todo"

pub fn main() {
  case argv.load().arguments {
    _ -> Ok(Nil)
  }
}

Calling argv.load().arguments will return a list with the command arguments.

For example, if the command is add "first todo", it will be ["add", "first todo"].

In Gleam, we use pattern matching instead of a switch statement.

For now, we are returning Ok(Nil) when nothing is matched.

JavaScript: add todo

Let’s implement it in a function called addTodo. It should take the file path where the todos are stored and the title of the new todo.

async function addTodo(filePath, todoTitle) {
  // we will implement it in a bit
}

Before I show you the code for it, we need to implement a helper function for handling file operations. In Node, to work with files properly, we need to access a file handle with the correct flags and close it properly after we’re done with it.

To make things easier, we will encapsulate all of this in a function called useTodoFile.

async function useTodoFile(filePath, callback) {
  // file handle
  let fd
  try {
    fd = await fs.open(filePath, 'a+')
    return await callback(fd)
  } finally {
    fd?.close()
  }
}

From now on, we will do all of our file operations through this function. The first argument will be the file path, and the second one is the callback where we will implement our code.

Since we’re using fs here, we need to import it at the top.

import * as fs from 'node:fs/promises'

Now, let’s get back to the addTodo function.

async function addTodo(filePath, todoTitle) {
  return await useTodoFile(filePath, async (fd) => {
    fd.appendFile(`[ ] ${todoTitle}\n`)
  })
}

We’ve defined the function, but we haven’t used it anywhere; we’ll do that in main().

async function main() {
  const { command, args } = getArgs()
  switch (command) {
    case 'add':
      await addTodo(FILE_PATH, args[0])
      break
  }
}

Gleam: add todo

First, let’s import simplifile and gleam/result at the top.

import simplifile
import gleam/result

Now let’s implement add_todo function.

pub fn add_todo(file_path: String, title: String) -> Result(Nil, Nil) {
  simplifile.append(file_path, "[ ] " <> title <> "\n")
  |> result.replace(Nil)
  |> result.nil_error
}

We are using simplifile.append to append the new todo to the file. Since the function expects Result(Nil, Nil) to be returned, I piped the result of simplifile.append to result.replace(Nil) to set Nil in the Ok field and result.nil_error to set Nil in the Error field.

It might be confusing at first if you’re not familiar with results in Gleam. But once you learn them, you’ll see how powerful error handling is in Gleam.

At this point, I encourage you to learn about Pipelines and Results.

Next, let’s call this function from main().

pub fn main() {
  case argv.load().arguments {
    ["add", title] -> add_todo(file_path, title)
    _ -> Ok(Nil)
  }
}

JavaScript: clear todos

This is the easiest feature to add. So, let’s define the function.

async function clearTodos(filePath) {
  return await useTodoFile(filePath, async (fd) => {
    await fd.truncate()
  })
}

And use it in main().

async function main() {
  const { command, args } = getArgs()
  switch (command) {
    case 'add':
      await addTodo(FILE_PATH, args[0])
      break
    case 'clear':
      await clearTodos(FILE_PATH)
      break
  }
}

Gleam: clear todos

To clear a file with simplifile, we just need to call simplifile.write with an empty string.

pub fn clear_todos(file_path: String) -> Result(Nil, Nil) {
  simplifile.write(file_path, "")
  |> result.replace(Nil)
  |> result.nil_error
}

Now, let’s use it in main().

pub fn main() {
  case argv.load().arguments {
    ["add", title] -> add_todo(file_path, title)
    ["clear"] -> clear_todos(file_path)
    _ -> Ok(Nil)
  }
}

JavaScript: list todos

When listing todos, we need to format them by replacing [ ] with their ordered number and strikethrough them if they are completed.

First, we need to get each line in the file, format it, and then join the lines again and save them in the file.

Here’s the function:

async function formattedTodos(filePath) {
  return await useTodoFile(filePath, async (fd) => {
    const content = await fd.readFile('utf8')
    return content
      .split('\n')
      .filter((title) => title)
      .map((title, index) => {
        const isDone = /^\[\*\]/.test(title)
        const rawTitle = title.match(/^\[[\s*]\]\s(.+)/)?.[1]
        return `${index + 1} ${isDone ? strikethrough(rawTitle) : rawTitle}`
      })
      .join('\n')
  })
}

function strikethrough(text) {
  return `\x1b[9m${text}\x1b[29m`
}

In this case, we are using ANSI escape codes. That’s why we prefix and suffix the text with some characters as shown in the code above.

Now, let’s call this function from main().

async function main() {
  const { command, args } = getArgs()
  switch (command) {
    case 'add':
      await addTodo(FILE_PATH, args[0])
      break
    case 'clear':
      await clearTodos(FILE_PATH)
      break
    case 'list':
      console.log(await formattedTodos(FILE_PATH))
      break
  }
}

Gleam: list todos

We are going to do the same in Gleam, but the code in my opinion is much cleaner.

import gleam/io
import argv
import simplifile
import gleam/string
import gleam/list
import gleam/int
import gleam/result
import gleam_community/ansi

// ...

pub fn formatted_todos(file_path: String) -> Result(String, Nil) {
  simplifile.read(file_path)
  |> result.map(split_to_lines)
  |> result.map(format_todo_lines)
  |> result.map(join_lines)
  |> result.nil_error
}

fn split_to_lines(content: String) -> List(String) {
  string.split(content, "\n")
}

fn join_lines(lines: List(String)) -> String {
  string.join(lines, "\n")
}

fn format_todo_lines(lines: List(String)) -> List(String) {
  list.index_map(lines, fn(line, index) {
    case line {
      "[ ] " <> rest -> int.to_string(index + 1) <> " " <> rest
      "[*] " <> rest -> int.to_string(index + 1) <> " " <> ansi.strikethrough(rest)
      _ -> line
    }
  })
}

The function we’re going to use in main() is formatted_todos. Thanks to pipelines, pattern matching, and Gleam’s nice syntax, I was able to split the operations into clear steps.

The bulk of the work is done in format_todo_line. It takes the list of lines in the file, and then using pattern matching, I replaced [ ] and [*] with the order number. Also note how I used the ansi package to strikethrough the completed todo.

Now, let’s call this from main().

pub fn main() {
  case argv.load().arguments {
    ["add", title] -> add_todo(file_path, title)
    ["clear"] -> clear_todos(file_path)
    ["list"] -> {
      case formatted_todos(file_path) {
        Ok(todos) -> Ok(io.println(todos))
        _ -> Ok(Nil)
      }
    }
    _ -> Ok(Nil)
  }
}

The only difference from other commands is that we need to print the result on the screen using io.println. But since Gleam requires handling errors when using results, I needed to match against Ok first to print the result.

JavaScript: mark todos done

For marking todos as completed, we need to implement the markDone function that takes the file path and the list of IDs to mark as done.

To mark a todo as done, we just need to put * in the [] before the todo title.

To do that in code, we need to split the file into lines, loop through them, only update the lines that are specified in the IDs array, and then store the lines back to the file.

async function markDone(filePath, ids) {
  return await useTodoFile(filePath, async (fd) => {
    const content = await fd.readFile('utf8')
    const lines = content.split('\n')
    ids.forEach((id) => {
      lines[id - 1] = lines[id - 1].replace(/^\[\s\]/, '[*]')
    })
    const updatedContent = lines.join('\n')
    await fd.truncate()
    await fd.writeFile(updatedContent)
  })
}

A line id here means the index + 1 of the line. So to get the line, I had to subtract one from the ID.

Next, let’s call it from main().

async function main() {
  const { command, args } = getArgs()
  switch (command) {
    case 'add':
      await addTodo(FILE_PATH, args[0])
      break
    case 'clear':
      await clearTodos(FILE_PATH)
      break
    case 'list':
      console.log(await formattedTodos(FILE_PATH))
      break
    case 'done':
      await markDone(FILE_PATH, args)
      break
  }
}

Gleam: mark todos done

The mark_done function in Gleam is similar to formatted_todos except that we mark them as done and we store the result into the file.

pub fn mark_done(file_path: String, ids: List(Int)) -> Result(Nil, Nil) {
  simplifile.read(file_path)
  |> result.map(split_to_lines)
  |> result.map(fn(lines) { mark_todo_done(lines, ids) })
  |> result.map(join_lines)
  |> result.try(fn(content) { simplifile.write(file_path, content) })
  |> result.nil_error
}

fn mark_todo_done(lines: List(String), ids: List(Int)) -> List(String){
  list.index_map(lines, fn(line, index) {
    case line {
      "[ ]" <> rest -> {
        case list.contains(ids, index + 1) {
          True -> "[*]" <> rest
          False -> line
        }
      }
      _ -> line
    }
  })
}

In mark_todo_done, I’m looping through the lines, then matching to see if the todo starts with [ ]. If it does, then check to see if the line’s index + 1 is in the ids list. If it is, then mark it as done; otherwise, return the line as is.

Now let’s update main() to use it.

pub fn main() {
  case argv.load().arguments {
    ["add", title] -> add_todo(file_path, title)
    ["clear"] -> clear_todos(file_path)
    ["list"] -> {
      case formatted_todos(file_path) {
        Ok(todos) -> Ok(io.println(todos))
        _ -> Ok(Nil)
      }
    }
    ["done", ..ids] -> mark_done(file_path, list.map(ids, fn(id) { result.unwrap(int.parse(id), 0) }))
    _ -> Ok(Nil)
  }
}

Let’s break down that code.

["done", ..ids] -> mark_done(
  file_path,
  list.map(ids, fn(id) {
    result.unwrap(int.parse(id), 0)
  }
))

In the first line, we’re matching against the done command and grabbing the passed IDs. In that case, we call mark_done and pass the file_path and then pass the ids list after we convert them from strings into integers.

result.unwrap here is to get the value in Ok that int.parse(id) returns. If parsing failed, then return 0 as default. In production code, it’s better if we match against the Error case and handle it. But for the sake of simplicity, let’s keep it like that.

JavaScript: run the app

To run the app in Node, you just need to run node todo.mjs [command]. So to add a new todo:

node todo.mjs add "first todo"

Then to see it run:

node todo.mjs list

And so on.

Gleam: run the app

To run the app in Gleam, run gleam run [command].

To add a new todo:

gleam run add "first todo"

And to show it:

gleam run list

And so on.

Verdict

Both languages are great. In the end, I was able to write the same app, and it runs as expected. However, because of its beautiful and concise syntax, error handling, and simple yet powerful dependencies, I enjoyed writing the Gleam version more.

Do you have any questions?
Stay up-to-date on the latest articles from Gleaming