Master Secrets in Gleam: Complete Learning Path

a close up of a computer screen with code on it

Master Secrets in Gleam: Complete Learning Path

Unlock the power of robust, maintainable, and secure Gleam applications by mastering one of its most fundamental concepts: secrets. This guide explains how Gleam uses opaque types to hide implementation details, providing a clean and stable public API that protects your code from unintended and breaking changes.


The Agony of Fragile Code: Why Hiding Details Matters

Imagine you've just built a brilliant Gleam library for managing user profiles. Your initial User type is a simple record: pub type User { name: String, login_attempts: Int }. Other developers on your team start using it everywhere. They directly access user.login_attempts to check for security risks. Everything works perfectly.

A month later, you decide to refactor. To improve performance, you change login_attempts to a more complex data structure, maybe a list of timestamps. You push your change, and suddenly, the entire application breaks. Dozens of modules that directly accessed the login_attempts field are now failing to compile. You've created a maintenance nightmare, all because an internal detail was exposed to the world.

This is the problem that "secrets" in Gleam—more formally known as encapsulation via opaque types—are designed to solve. It’s not about cryptography; it's about creating a clear boundary between what the outside world can see (the public API) and the complex, ever-changing machinery hidden inside (the implementation details). By mastering this, you can refactor your internal code with confidence, knowing you won't break the applications that depend on it.


What Exactly is a "Secret" in Gleam?

In the context of the Gleam language and the kodikra learning path, a "secret" refers to the practice of information hiding. The primary tool Gleam provides for this is the pub opaque type declaration. When you define a type as opaque, you are making a promise to the compiler and other programmers: "You can know that this type exists, but you cannot know what it is made of or how it works internally."

Think of it like a car's ignition system. As a driver, you have a public API: the keyhole (or a start button). You use this interface to start the car. You don't need to know about the spark plugs, the battery voltage, or the fuel injection sequence. The car's engineers have hidden those implementation details from you.

This abstraction is powerful because the engineers can completely redesign the engine—switching from a gasoline engine to an electric motor—but the public API, the "start" button, remains the same. Your experience as a driver is unaffected. Opaque types bring this same level of stability and abstraction to your software.

The Core Syntax: pub type vs. pub opaque type

To understand opaque types, it's crucial to first understand their transparent counterpart, the public type.

  • pub type User { name: String }: This makes the User type and its internal fields (name) public. Any other module can import User, create instances of it directly with User(name: "Alice"), and access its fields with my_user.name.
  • pub opaque type User: This makes the name of the type User public, but nothing else. Other modules can see that a type named User exists and can hold values of this type, but they cannot create instances directly or inspect their internal fields. They are completely reliant on the public functions you provide in the same module.
// in a file named user.gleam

// The internal structure is defined here, but hidden from the outside.
pub opaque type User {
  id: String,
  name: String,
  is_verified: Bool,
}

// A "constructor" function that acts as the public entry point.
pub fn create_new_user(name: String) -> User {
  User(
    id: "some_random_uuid", // Internal detail
    name: name,
    is_verified: False, // Internal detail
  )
}

// An "accessor" function to safely read a value.
pub fn get_name(user: User) -> String {
  user.name
}

// An "updater" function to safely modify state.
pub fn verify_user(user: User) -> User {
  User(..user, is_verified: True)
}

In another file, you can use these functions, but you can't cheat:

// in another file, e.g., main.gleam
import user

pub fn main() {
  // CORRECT: Using the public API
  let alice = user.create_new_user("Alice")
  let alices_name = user.get_name(alice)

  // INCORRECT: This will cause a compile-time error!
  // The compiler stops you from accessing internal fields.
  // let is_verified = alice.is_verified 
  // Error: The field `is_verified` is not defined for the type `user.User`.
}

This compile-time guarantee is what makes the pattern so powerful. It's not just a convention; it's enforced by the language itself.


Why Is Information Hiding a Superpower for Developers?

Adopting opaque types isn't just about writing cleaner code; it fundamentally changes how you design and maintain software. It provides tangible benefits that lead to more robust, secure, and long-lasting applications.

1. Freedom to Refactor

This is the most significant benefit. When your type's internal structure is hidden, it acts as a contract. As long as your public functions continue to accept the same inputs and produce the same outputs, you are free to change the internals completely. You can switch from a List to a Map, add caching, or change data representations without causing a single compile error in any downstream code.

2. Enforcing Invariants (Guaranteed Correctness)

An "invariant" is a rule about your data that must always be true. Opaque types are the perfect tool to enforce them. For example, you could create a NonEmptyList type that is guaranteed to always contain at least one element.

// in non_empty_list.gleam
import gleam/list

pub opaque type NonEmptyList(a) {
  head: a,
  tail: List(a),
}

// The only way to create a NonEmptyList is through this function.
pub fn from_list(list: List(a)) -> Result(NonEmptyList(a), Nil) {
  case list {
    [] -> Error(Nil)
    [head, ..tail] -> Ok(NonEmptyList(head: head, tail: tail))
  }
}

// This function can now safely assume the list is not empty.
pub fn get_head(nel: NonEmptyList(a)) -> a {
  nel.head // No need for a `case` statement to check for empty!
}

Anywhere in your program that you see a value of type NonEmptyList, you have a compile-time guarantee that it's not empty. This eliminates entire classes of bugs, like "index out of bounds" or "called head on an empty list."

3. Simpler and More Expressive APIs

When users of your module don't have to worry about the internal structure, they can focus on the high-level operations you provide. A good API tells a story about what can be done, not how it's done. Hiding the "how" leads to a much cleaner and more intuitive developer experience.

4. Enhanced Security

By controlling how data is created and modified, you can prevent objects from entering an invalid or insecure state. Imagine a Session type that contains a user ID and an expiration timestamp. If the type were transparent, another part of the code could accidentally (or maliciously) create a session with an expiration date in the past. With an opaque type, you can force all session creation through a `create_session` function that guarantees a valid future expiration.


How to Implement Secrets in Your Gleam Code: A Practical Walkthrough

Let's build a practical example: a simple "safe" counter that can only be incremented or decremented, never set to an arbitrary value. This prevents other parts of the code from accidentally resetting or corrupting the count.

Step 1: Define the Opaque Type and Module

Create a new file, safe_counter.gleam. Inside, we define our module and the opaque type. The internal representation will just be an integer, but no one outside this file will know that.

// in safe_counter.gleam

pub opaque type SafeCounter {
  value: Int,
}

Step 2: Create the Public "Constructor"

Since no one can create a SafeCounter directly, we must provide a public function to do so. This function will be the sole entry point for creating new counters.

// in safe_counter.gleam (continued)

/// Creates a new counter, initialized to a starting value.
pub fn new(initial_value: Int) -> SafeCounter {
  SafeCounter(value: initial_value)
}

Step 3: Build the Public API for Interaction

Now, we add the functions that define how the world can interact with our SafeCounter. We'll add functions to increment, decrement, and get the current value.

// in safe_counter.gleam (continued)

/// Returns a new counter with its value increased by one.
pub fn increment(counter: SafeCounter) -> SafeCounter {
  SafeCounter(value: counter.value + 1)
  // Note: We could also write `SafeCounter(..counter, value: counter.value + 1)`
}

/// Returns a new counter with its value decreased by one.
pub fn decrement(counter: SafeCounter) -> SafeCounter {
  SafeCounter(value: counter.value - 1)
}

/// Returns the current integer value of the counter.
pub fn get_value(counter: SafeCounter) -> Int {
  counter.value
}

Step 4: Use the Module from an External File

In another file, like main.gleam, we can now use our safe counter, confident that we can only interact with it through the approved API.

// in main.gleam
import gleam/io
import safe_counter

pub fn main() {
  let mut my_counter = safe_counter.new(10)
  io.println("Initial value: " <> int.to_string(safe_counter.get_value(my_counter)))

  my_counter = safe_counter.increment(my_counter)
  my_counter = safe_counter.increment(my_counter)
  io.println("After two increments: " <> int.to_string(safe_counter.get_value(my_counter)))

  // This line would fail to compile!
  // my_counter.value = 99
  // Error: The field `value` is not defined for the type `safe_counter.SafeCounter`.
}

By following this pattern, we've created a small, robust, and completely encapsulated piece of logic. We are now free to change the internal implementation of SafeCounter—perhaps to add logging or bounds checking—without ever breaking the main module.

Visualizing the Encapsulation Flow

This diagram illustrates the boundary created by the opaque type. Your application code can only interact with the module through the designated public functions, which act as gatekeepers to the hidden internal state.

    ● Your Application Code (main.gleam)
    │
    │ "I need a new counter."
    │
    ▼
    ┌───────────────────────────────┐
    │ Calls `safe_counter.new(10)`  │
    └──────────────┬────────────────┘
                   │
                   │ Passes through the Public API "gate"
                   ▼
    ╔═══════════════════════════════════╗
    ║      `safe_counter` Module        ║
    ╟───────────────────────────────────╢
    ║                                   ║
    ║   pub fn new(initial: Int) { ... }║
    ║   pub fn increment(...) { ... }   ║
    ║   pub fn get_value(...) { ... }   ║
    ║                                   ║
    ║   // This part is hidden
    ║   pub opaque type SafeCounter {   ║
    ║     value: Int                    ║
    ║   }                               ║
    ║                                   ║
    ╚═══════════════════════════════════╝
                   │
                   │ Returns an opaque `SafeCounter` instance
                   ▼
    ● Your Application Code (main.gleam)
    │
    │ Holds a `SafeCounter` value.
    │ It cannot inspect `value` directly,
    │ only call `increment` or `get_value`.

Where to Use Opaque Types: Real-World Applications

The concept of hiding implementation details is not just theoretical. It is used extensively in professional software development to build reliable systems. Here are some common use cases:

  • Validated Data Types: Creating types like EmailAddress, PhoneNumber, or PositiveInteger that can only be constructed through a parsing function. This guarantees that if you have a value of that type, it has already been validated.
  • Database Connection Handles: A module managing a database connection pool can expose an opaque DbConnection type. Users of the module can get a connection and run queries with it, but they can't access the raw TCP socket or authentication details hidden inside.
  • API Client State: When writing a client for a third-party API, you can store the API key, base URL, and an HTTP client instance inside an opaque ApiClient type. Public functions would then expose methods like `get_users(client)` or `post_message(client, message)`.
  • Finite State Machines: An opaque type can represent the state of a system, and public functions can represent the valid transitions between states, preventing the system from ever entering an impossible state.

Visualizing the Data Validation Lifecycle

This flow diagram shows the common pattern of taking untrusted, raw input and converting it into a trusted, opaque type through a validation function.

    ● Raw, Untrusted Input
      (e.g., a String from an HTML form: "not-an-email")
      │
      ▼
    ┌──────────────────────────────────┐
    │ `email.from_string("not-an-email")` │
    │    (The public parsing function)   │
    └─────────────────┬──────────────────┘
                      │
                      ▼
               ◆ Is string a valid email format?
              ╱                             ╲
         Yes ╱                               ╲ No
            │                                 │
            ▼                                 ▼
    ┌──────────────────┐             ┌──────────────────┐
    │ Ok(Email(...))   │             │ Error(InvalidFormat) │
    └──────────────────┘             └──────────────────┘
            │
            │ The returned `Email` type is opaque.
            │ Its internal value is now guaranteed
            │ to be a correctly formatted email
            │ string for the rest of its life.
            ▼
    ● A Trusted, Opaque `Email` Type

Pros and Cons: When to Be Secretive

Like any tool, opaque types are powerful but not always necessary. Knowing when to use them is a mark of an experienced developer.

Pros (When to Use Opaque Types) Cons (When to Reconsider)
When the internal representation is likely to change. If you anticipate refactoring the data structure, an opaque type is your best friend. For simple, stable data transfer objects (DTOs). If a type is just a plain bag of data with no special rules (e.g., a JSON response), a public `type` is often simpler.
When you need to enforce invariants. Any time you have rules that must always be true for your data (e.g., "this list is never empty"), use an opaque type. For modules internal to your own application. If a type is only used by one or two other modules that you also control, the extra boilerplate might not be worth it.
When designing a public library for others to use. Encapsulation is non-negotiable for library authors. It protects your users from breaking changes. When you need direct pattern matching on the structure. Callers cannot pattern match on the internals of an opaque type, which is by design. If this is a primary requirement, the type should be public.
To simplify a complex API. Hiding dozens of internal fields and exposing 3-4 high-level functions makes your module much easier to use. During early-stage prototyping. Sometimes, in the very early, messy stages of a project, it's faster to use public types and add encapsulation later once the design stabilizes.

Your Learning Path: The Secrets Module

The theory is essential, but the best way to truly understand the power of opaque types is to put them into practice. The kodikra.com Gleam learning path includes a dedicated module designed to solidify these concepts.

In this module, you will be tasked with implementing a system that uses bitwise operations to encode and decode a secret handshake. By making your primary data type opaque, you will be forced to create a clean public API, hiding the complex and messy bit-shifting logic from the end-user. This is a perfect, hands-on application of the principles discussed in this guide.

  • Learn Secrets step by step: Dive into this practical challenge to apply your knowledge of opaque types, public functions, and information hiding.

Completing this exercise will give you the confidence to identify opportunities for encapsulation in your own projects and to build more robust and maintainable Gleam applications.


Frequently Asked Questions (FAQ)

1. Is an opaque type in Gleam the same as a private class in an object-oriented language like Java or C#?
They solve the same problem—encapsulation—but the mechanism is different. A private class combines data and behavior into one unit. Gleam's approach is more functional: the data (the opaque type) is separate from the behavior (the public functions in the module). The result is similar: controlled access to internal state.
2. Can I use pattern matching on an opaque type?
From outside the defining module, no. You cannot inspect the internal structure of an opaque type. Inside the module where the type is defined, you can pattern match on it freely because its structure is known. This is a key part of the "information hiding" contract.
3. What is the performance impact of using opaque types?
There is zero runtime performance impact. Opaque types are a compile-time concept. The compiler uses them to enforce access rules before your code is ever run. Once compiled (to Erlang or JavaScript), the code runs as if the types were transparent; the safety checks have already been completed.
4. How can I serialize an opaque type to JSON or another format?
You must provide a public function in the module to handle it, such as `to_json(my_opaque_value)`. This function, being inside the module, has access to the internal fields and can correctly construct the desired representation. This also gives you control over what data gets exposed during serialization.
5. What is the exact difference between `pub type MyType` and `pub opaque type MyType`?
With `pub type MyType { field: Int }`, external modules can see the type name (`MyType`), its constructor (`MyType(...)`), and all its fields (`.field`). With `pub opaque type MyType`, external modules can only see the type name (`MyType`). They cannot see the constructor or any of the fields.
6. How do I effectively test a module that uses an opaque type?
You test it through its public API, just like any other consumer of the module would. Write tests that call your public functions (`new`, `increment`, `get_value`, etc.) and assert that they behave as expected. This approach, known as black-box testing, has the added benefit that your tests won't break if you refactor the internal implementation.
7. Can an opaque type be a generic type?
Yes, absolutely. The `NonEmptyList(a)` example shown earlier is a generic opaque type. This allows you to create encapsulated data structures that can hold any type of value, providing both type safety and implementation hiding.

Conclusion: Build Code That Lasts

Mastering secrets and opaque types is a significant step towards becoming a proficient Gleam developer. It's a shift from merely writing code that works *now* to engineering systems that are resilient, maintainable, and easy to reason about in the long term. By creating clear boundaries and hiding implementation details, you give yourself the freedom to evolve and improve your code without fear of causing cascading failures.

This principle of encapsulation is a cornerstone of modern software design. Gleam’s first-class support for opaque types makes it an elegant and powerful tool in your arsenal. As you continue your journey, challenge yourself to identify where you can apply this pattern to make your own codebases stronger and more secure.

Disclaimer: All code examples are written for Gleam v1.3.0 or later. Syntax and standard library functions may differ in older versions of the language.

Back to the Complete Gleam Guide

Explore the Full Gleam Learning Roadmap


Published by Kodikra — Your trusted Gleam learning resource.