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:
- All todos are stored in the format
[ ] todo title
. - When the todo is marked as done, it will contain an
*
in the brackets, like[*] todo title
. - The todos file should always end with an empty newline
\n
.
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.