codeWithYoha logo
Code with Yoha
HomeArticlesAboutContact
Gleam

Introduction to Gleam: A Type-Safe Language for the Erlang VM

CodeWithYoha
CodeWithYoha
14 min read
Introduction to Gleam: A Type-Safe Language for the Erlang VM

Introduction

The Erlang Virtual Machine (BEAM) is renowned for its unparalleled capabilities in building highly concurrent, fault-tolerant, and distributed systems. For decades, Erlang and more recently Elixir have been the primary languages leveraging this robust runtime. However, a growing demand for static type safety in this ecosystem has led to the emergence of new contenders. Enter Gleam: a friendly, expressive, and type-safe language that compiles to the Erlang VM, bringing the best of both worlds – BEAM's reliability and static typing's confidence.

Gleam aims to provide a delightful developer experience by offering a powerful type system without sacrificing the BEAM's core strengths. It's designed to be approachable, with a clear, modern syntax, making it an excellent choice for developers looking to build robust applications with strong guarantees, from web services to distributed backends.

This comprehensive guide will introduce you to Gleam, covering its core philosophy, setting up your environment, exploring its type system and concurrency model, and demonstrating how it integrates seamlessly into the Erlang ecosystem.

Prerequisites

To get the most out of this guide, a basic understanding of programming concepts is helpful. Familiarity with functional programming paradigms or the Erlang VM (BEAM) will be an advantage but is not strictly required, as we'll cover the essentials. You'll need a command-line interface and an internet connection to install Gleam.

The Erlang VM and Why Gleam Fits

The BEAM is a marvel of engineering, purpose-built for soft real-time systems that demand high availability and low latency. Its key features include:

  • Lightweight Processes: Millions of isolated processes can run concurrently with minimal overhead.
  • Message Passing: Processes communicate by sending immutable messages, avoiding shared state issues.
  • Fault Tolerance: The "let it crash" philosophy, coupled with supervisors, allows systems to recover from failures gracefully.
  • Hot Code Swapping: The ability to update running code without downtime.

Gleam embraces these fundamental BEAM principles. Instead of reinventing the wheel, it provides a language layer that compiles down to efficient Erlang bytecode. This means Gleam programs benefit directly from the BEAM's battle-tested runtime, its scheduler, garbage collector, and OTP (Open Telecom Platform) libraries. Gleam's type system then adds another layer of robustness, catching entire classes of errors at compile-time that might otherwise only appear at runtime in dynamically typed languages.

What Makes Gleam Unique?

Gleam distinguishes itself with several core features:

  • Static Type System: This is Gleam's flagship feature. It provides strong type inference, meaning you often don't need to write explicit type annotations, but the compiler still ensures type safety. This prevents many common bugs before your code even runs.
  • Functional Programming: Gleam is a functional language through and through. It emphasizes immutability, pure functions, and expressions over statements, leading to more predictable and testable code.
  • Friendly Syntax: Inspired by languages like Rust and Go, Gleam's syntax is clean, modern, and easy to read, reducing the learning curve for developers coming from various backgrounds.
  • Erlang VM Integration: Seamless interoperability with existing Erlang and Elixir libraries allows Gleam projects to leverage the vast BEAM ecosystem.
  • JavaScript Compilation: Beyond the BEAM, Gleam can also compile to JavaScript, enabling full-stack type safety with a single language.

These features combine to offer a powerful yet approachable language for building reliable, maintainable systems.

Setting Up Your Gleam Environment

Getting started with Gleam is straightforward. We'll use gleam as our package manager and build tool, which in turn uses hex and rebar3 under the hood for BEAM dependencies.

1. Install Gleam

The recommended way to install Gleam is via gleam_install script or a package manager:

macOS/Linux (using gleam_install):

curl -Ls https://raw.githubusercontent.com/gleam-lang/gleam/main/scripts/gleam_install.sh | sh

Windows (via Scoop):

scoop install gleam

After installation, verify it by running:

gleam --version

This should output the installed Gleam version.

2. Create a New Project

Navigate to your desired directory and create a new Gleam project:

gleam new my_gleam_app
cd my_gleam_app

This command creates a new directory my_gleam_app with a basic project structure, including src/my_gleam_app.gleam and gleam.toml.

Your First Gleam Program: Hello World

Let's modify the src/my_gleam_app.gleam file to print "Hello, Gleam!".

// src/my_gleam_app.gleam
import gleam/io

pub fn main() {
  io.println("Hello, Gleam!")
}

To run this program, use the gleam run command:

gleam run

You should see Hello, Gleam! printed to your console. The gleam run command compiles your project, and then executes the main function defined in your main module (specified in gleam.toml).

Understanding Gleam's Type System

Gleam's type system is powerful yet unintrusive. It uses type inference heavily, allowing you to write less boilerplate while still enjoying strong type guarantees. Let's explore some core aspects.

Primitive Types

Gleam supports common primitive types:

  • Int: Integers (e.g., 1, -100)
  • Float: Floating-point numbers (e.g., 3.14, 0.5)
  • Bool: Booleans (True, False)
  • String: UTF-8 strings (e.g., "hello")
  • BitArray: Binary data
// Example of primitive types
pub fn show_primitives() {
  let an_int: Int = 42
  let a_float: Float = 3.14159
  let a_bool: Bool = True
  let a_string: String = "Gleam is great!"

  // Type inference often makes explicit annotations unnecessary
  let inferred_int = 100
  let inferred_string = "Another string"

  io.println(int_to_string(an_int))
  io.println(float_to_string(a_float))
  io.println(bool_to_string(a_bool))
  io.println(a_string)
}

Custom Types: Enums and Structs

Gleam allows you to define your own custom types, which are crucial for modeling domain-specific concepts.

  • Enums (Algebraic Data Types): Represent a value that can be one of several distinct forms. They are excellent for representing states or different kinds of data.

    // src/my_gleam_app.gleam
    import gleam/io
    import gleam/string
    
    pub type TrafficLight {
      Red
      Yellow
      Green
      Flashing(Int) // Enums can carry data
    }
    
    pub fn traffic_light_message(light: TrafficLight) -> String {
      case light {
        Red -> "Stop!"
        Yellow -> "Prepare to stop or go"
        Green -> "Go!"
        Flashing(count) -> "Flashing " <> int_to_string(count) <> " times. Proceed with caution."
      }
    }
    
    pub fn main() {
      io.println(traffic_light_message(Green))
      io.println(traffic_light_message(Flashing(3)))
    }
  • Structs (Records): Represent a collection of named fields, similar to records or objects in other languages, but immutable.

    pub type User {
      id: Int
      name: String
      email: String
      is_active: Bool
    }
    
    pub fn create_user(id: Int, name: String, email: String) -> User {
      User(id: id, name: name, email: email, is_active: True)
    }
    
    pub fn deactivate_user(user: User) -> User {
      // Struct update syntax to create a new user with updated field
      User(..user, is_active: False)
    }
    
    pub fn main() {
      let alice = create_user(1, "Alice", "alice@example.com")
      io.println("User: " <> alice.name <> ", Active: " <> bool_to_string(alice.is_active))
    
      let deactivated_alice = deactivate_user(alice)
      io.println("User: " <> deactivated_alice.name <> ", Active: " <> bool_to_string(deactivated_alice.is_active))
    }

Pattern Matching

Pattern matching is a fundamental control flow mechanism in Gleam, especially powerful when combined with enums. It allows you to deconstruct data structures and execute different code paths based on the shape of the data.

// Reusing the TrafficLight enum from above
pub fn handle_traffic_light(light: TrafficLight) -> String {
  case light {
    Red -> "Drivers must stop."
    Yellow -> "Be ready to change."
    Green -> "All clear to proceed."
    Flashing(1) -> "Emergency flash. Single flash."
    Flashing(n) -> "Flashing " <> int_to_string(n) <> " times. Caution!"
  }
}

pub fn main() {
  io.println(handle_traffic_light(Red))
  io.println(handle_traffic_light(Flashing(1)))
  io.println(handle_traffic_light(Flashing(5)))
}

Concurrency with Gleam

Gleam leverages the BEAM's actor model for concurrency. This means you interact with isolated, lightweight processes that communicate via message passing. The gleam/otp module provides abstractions similar to Erlang's OTP behaviors.

Let's create a simple "ping-pong" example using Gleam processes.

// src/my_gleam_app.gleam
import gleam/io
import gleam/otp/process
import gleam/result
import gleam/string

pub type Message {
  Ping(process.ProcessId)
  Pong
  Stop
}

fn ping_process(receiver: process.ProcessId) {
  io.println("Ping: Sending Ping...")
  process.send(receiver, Ping(process.this()))
  case process.receive(1000) {
    Ok(Pong) -> io.println("Ping: Received Pong!")
    Ok(Stop) -> io.println("Ping: Stopping.")
    _ -> io.println("Ping: Timeout or unexpected message.")
  }
}

fn pong_process() {
  io.println("Pong: Waiting for Ping...")
  case process.receive(5000) {
    Ok(Ping(sender)) -> {
      io.println("Pong: Received Ping. Sending Pong...")
      process.send(sender, Pong)
      // Continue waiting or stop
      case process.receive(1000) {
        Ok(Stop) -> io.println("Pong: Stopping.")
        _ -> io.println("Pong: No further messages.")
      }
    }
    _ -> io.println("Pong: Timeout or unexpected message.")
  }
}

pub fn main() {
  io.println("Starting ping-pong example...")

  let pong_pid = process.start(pong_process)
  let _ = process.start_link(fn() { ping_process(pong_pid) })

  // Give processes time to run
  process.sleep(2000)

  // Send stop messages (optional, for clean shutdown in a real app)
  process.send(pong_pid, Stop)
  process.sleep(100) // Give time to process stop

  io.println("Ping-pong example finished.")
}

This example demonstrates process.start, process.send, and process.receive for inter-process communication. Each ping_process and pong_process runs in its own lightweight BEAM process.

Immutability and Functional Programming

Gleam enforces immutability by default. Once a value is created, it cannot be changed. Instead, operations that appear to modify data (like deactivate_user for a struct) actually return a new value with the desired changes, leaving the original intact.

This design choice eliminates an entire class of bugs related to shared mutable state, making code easier to reason about, test, and parallelize. Functions in Gleam are typically pure: given the same inputs, they will always produce the same output and have no side effects.

// Functions as first-class citizens
pub fn add(a: Int, b: Int) -> Int {
  a + b
}

pub fn apply_operation(x: Int, y: Int, op: fn(Int, Int) -> Int) -> Int {
  op(x, y)
}

pub fn main() {
  let sum = apply_operation(10, 5, add)
  io.println("Sum: " <> int_to_string(sum))

  // Anonymous function (lambda)
  let product = apply_operation(10, 5, fn(a, b) { a * b })
  io.println("Product: " <> int_to_string(product))
}

Error Handling in Gleam

Gleam promotes explicit error handling using the Result type, which is an enum representing either success (Ok) or failure (Error). This forces developers to consider failure cases explicitly, leading to more robust applications.

// src/my_gleam_app.gleam
import gleam/io
import gleam/result
import gleam/string

pub type ParseError {
  NotAnInt
  TooLarge
}

pub fn parse_int(s: String) -> Result(Int, ParseError) {
  case string.to_int(s) {
    Ok(n) -> {
      case n > 1000 {
        True -> Error(TooLarge)
        False -> Ok(n)
      }
    }
    Error(_) -> Error(NotAnInt)
  }
}

pub fn main() {
  case parse_int("123") {
    Ok(value) -> io.println("Parsed: " <> int_to_string(value))
    Error(NotAnInt) -> io.println("Error: Input was not an integer.")
    Error(TooLarge) -> io.println("Error: Input was too large.")
  }

  case parse_int("abc") {
    Ok(value) -> io.println("Parsed: " <> int_to_string(value))
    Error(NotAnInt) -> io.println("Error: Input was not an integer.")
    Error(TooLarge) -> io.println("Error: Input was too large.")
  }

  case parse_int("12345") {
    Ok(value) -> io.println("Parsed: " <> int_to_string(value))
    Error(NotAnInt) -> io.println("Error: Input was not an integer.")
    Error(TooLarge) -> io.println("Error: Input was too large.")
  }
}

While panic exists for unrecoverable errors, the idiomatic Gleam approach is to use Result for expected failures.

Interoperability with Erlang/Elixir

One of Gleam's significant advantages is its seamless interoperability with the existing BEAM ecosystem. You can call Erlang and Elixir functions directly from Gleam and vice versa.

Calling Erlang/Elixir from Gleam

You can define external functions in your gleam.toml to map to Erlang/Elixir modules and functions.

Let's assume you have an Erlang module my_erlang_lib.erl:

% my_erlang_lib.erl
-module(my_erlang_lib).
-export([greet/1]).

greet(Name) ->
    io:format("Hello from Erlang, ~s!\n", [Name]).

First, make sure this Erlang file is part of your Gleam project's source or a dependency. Then, in Gleam, you'd declare an external function:

// src/my_gleam_app.gleam
import gleam/io

// Declare an external function that corresponds to an Erlang function
@external(erlang, "my_erlang_lib", "greet")
pub fn greet_erlang(name: String) -> Nil

pub fn main() {
  io.println("Calling Erlang from Gleam...")
  greet_erlang("Gleamer")
}

When you run this, the Erlang function will be invoked.

Calling Gleam from Erlang/Elixir

Since Gleam compiles to Erlang, its compiled modules are just regular Erlang modules. You can call Gleam functions from Erlang or Elixir just like any other BEAM module.

If your Gleam module is my_gleam_app and it has a public function my_function(arg1: Int) -> String, in Elixir you would call it as:

# In an Elixir IEx session or file
:my_gleam_app.my_function(123)

This tight integration means you can gradually introduce Gleam into existing BEAM projects or leverage the vast library ecosystem without rewriting everything.

Building and Testing Gleam Projects

The gleam command-line tool is your primary interface for managing projects.

  • gleam build: Compiles your Gleam code and its dependencies into Erlang bytecode. The compiled artifacts are placed in the build directory.

    gleam build
  • gleam test: Runs your project's tests. Gleam has a built-in test runner. Tests are typically placed in a test/ directory, mirroring your src/ structure.

    // test/my_gleam_app_test.gleam
    import gleam/test
    import gleam/expect
    import my_gleam_app
    
    pub fn hello_test() {
      test.case("hello returns a string", fn() {
        expect.equal(my_gleam_app.hello(), "Hello from my_gleam_app!")
      })
    }

    (Assuming my_gleam_app.gleam has a pub fn hello() -> String that returns "Hello from my_gleam_app!")

    gleam test
  • gleam format: Formats your Gleam code according to a standard style guide, ensuring consistency across your codebase.

    gleam format
  • gleam docs: Generates HTML documentation for your project.

    gleam docs

Real-World Use Cases and Best Practices

Gleam is well-suited for a variety of applications, especially those benefiting from the BEAM's concurrency and fault tolerance, combined with static type safety.

Use Cases

  • Web Services/APIs: Building robust, high-performance web backends using frameworks like Lustre or Mint.
  • Distributed Systems: Leveraging the BEAM's capabilities for message queues, real-time communication, and resilient microservices.
  • CLI Tools: Its compilation to JavaScript also makes it suitable for CLI tools that can run cross-platform (Node.js).
  • Data Processing: Type safety can be invaluable in systems processing complex data flows.

Best Practices

  • Embrace the Type System: Let the compiler guide you. Use custom types (enums, structs) to model your domain precisely. This often leads to "if it compiles, it works" confidence.
  • Favor Immutability and Pure Functions: Design functions that take inputs and produce outputs without side effects. This makes code easier to test, debug, and reason about.
  • Explicit Error Handling with Result: Always prefer Result for recoverable errors. Reserve panic for truly unrecoverable situations or programmer errors.
  • Small, Focused Functions: Break down complex logic into smaller, single-purpose functions. This improves readability and reusability.
  • Leverage Pattern Matching: Use case expressions for clear, exhaustive handling of different data shapes, especially with enums.
  • Interoperate Wisely: When integrating with Erlang/Elixir, understand the type boundaries. Gleam's types map to Erlang terms, so ensure consistency.
  • Testing is Key: Write comprehensive unit tests. Gleam's functional nature and immutability make testing straightforward.

Common Pitfalls and How to Avoid Them

While Gleam is designed to be friendly, new users might encounter some common hurdles:

  • Over-reliance on panic: Coming from languages where exceptions are common, there's a temptation to use panic for all error conditions. This bypasses the type system's error guarantees. Avoidance: Use the Result type for all expected failure modes.
  • Fighting the Type System: Sometimes, a developer might try to force a dynamic-language pattern into Gleam, leading to complex or awkward type annotations. Avoidance: Re-evaluate your data modeling. Often, a well-designed enum or struct can simplify type interactions significantly.
  • Misunderstanding BEAM Concurrency: While Gleam makes processes easy to start, understanding message passing semantics, process linking, and supervision is crucial for building robust concurrent systems. Avoidance: Read up on Erlang's OTP principles. Start with simple concurrent patterns before moving to complex supervision trees.
  • Ignoring Immutability: Attempting to mutate data directly or expecting functions to alter their arguments. Avoidance: Remember that operations return new values. Embrace the functional style of transforming data.
  • Unused Result values: Forgetting to handle the Error branch of a Result type. The compiler will warn you about unused Result values, but it's easy to ignore. Avoidance: Always case over Result or use functions like result.unwrap (with caution) where appropriate.

Conclusion

Gleam offers a compelling proposition: the robust, concurrent, and fault-tolerant power of the Erlang VM, enhanced with a modern, friendly syntax and the confidence of a strong static type system. It's a language built for reliability and developer joy, bridging the gap between the dynamic flexibility of Erlang/Elixir and the compile-time guarantees of languages like Rust or Haskell.

By embracing Gleam, developers can build systems that are not only highly performant and resilient but also easier to maintain, reason about, and scale. As the language continues to mature and its ecosystem expands, Gleam is poised to become a significant player in the world of distributed systems and beyond.

Are you ready to build type-safe, fault-tolerant applications? Dive into Gleam and experience the future of BEAM development!

CodewithYoha

Written by

CodewithYoha

Full-Stack Software Engineer with 5+ years of experience in Java, Spring Boot, and cloud architecture across AWS, Azure, and GCP. Writing production-grade engineering patterns for developers who ship real software.