![Enhancing Your Elixir Codebase with Gleam](/_next/image?url=%2Fimages%2Fblog%2F2024-07%2Felixir-and-gleam.jpg&w=3840&q=50)
Do you write Elixir but sometimes miss the benefits of type safety we have in other languages? If the answer is "yes", you may want to take a look at Gleam. It is a relatively young language that runs on top of the BEAM platform, which means it can be added as an enhancement to an Elixir codebase without you having to rewrite everything.
In this article, we will add Gleam code to an Elixir project.
Why Use Gleam for Elixir?
First, let's ask ourselves a question: why would we use Gleam? The Elixir ecosystem is mature at this point, with a multitude of great solutions for everyday problems. Ecto and Phoenix make a really powerful combination for writing web applications. Elixir itself is a dynamically typed language, which is great for some applications but opens the door to a whole class of errors.
Imagine you're working on a critical payment processing system in Elixir and need to ensure your core logic's absolute reliability and correctness. Despite Elixir's strengths, its dynamic typing sometimes leaves room for subtle bugs. Enter Gleam. It's a statically typed language for the BEAM platform that promises to enhance your system's robustness. Where Elixir falls a bit short, Gleam can shine. Let's explore how to integrate Gleam with an Elixir project, so we can have the best from both languages.
We want our core business logic to be written in Gleam while the surrounding code is in Elixir (somewhat akin to the idea of a "functional core, imperative shell", although in the functional world, it's rather a "pure core, stateful shell"). We will take this idea very far, establishing a physical boundary between the two worlds.
Let's see how to get there.
About The Project
To witness the power of Gleam in action, we will implement a feature in a made-up application to manage students at a university. We will take care of their enrollment in courses. Our business rules will look as follows:
- A student can be enrolled in a course.
- Every course has a limited number of seats and a finite-length waitlist. If a student tries to enroll but all the seats are already taken, they are put on the waitlist (if there are spots there; if not — the enrollment is rejected).
- If someone with a reserved seat cancels their enrollment, the first person on the waitlist takes their place.
- Some courses have age limits: only students older than a certain age can enroll.
We will start the implementation by creating a fairly standard Phoenix and Ecto application:
mix phx.new student_roll mix ecto.create mix phx.gen.live Enrollment Student students name:string date_of_birth:date mix phx.gen.live Enrollment Course courses name:string max_students:integer waitlist_size:integer min_age:integer mix phx.gen.schema Enrollment.Enrollment enrollments student_id:integer course_id:integer waitlisted_at:datetime mix ecto.migrate
With this basic structure in place, we can add Gleam into the mix using the
mix_gleam
library. We will follow the steps from the
project README. First, install mix_gleam
:
mix archive.install hex mix_gleam
Now prepare the project to use Gleam by adding some entries to mix.exs
's project definition:
@app_name :student_roll def project do [ archives: [mix_gleam: "~> 0.6"], app: @app_name, compilers: [:gleam | Mix.compilers()], aliases: [ "deps.get": ["deps.get", "gleam.deps.get"] ], erlc_paths: [ "build/dev/erlang/#{@app_name}/_gleam_artefacts" ], erlc_include_path: "build/dev/erlang/#{@app_name}/include", prune_code_paths: false, # rest of the function ] end
Note that this assumes you are changing an existing mix.exs
file generated by phx.new
. These are required steps
to make the Gleam code work with Elixir, as outlined in the documentation. You don't need to understand every single
change here (I, for instance, don't). The important thing is erlc_path
which tells the compiler where to find compiled
Gleam files, so we can use them in our project.
Next, also in mix.exs
, we need to add the Gleam standard library and testing framework to our dependencies:
defp deps do [ # others {:gleam_stdlib, "> 1.0"}, {:gleeunit, "~> 1.0", only: [:dev, :test], runtime: false} ] end
Finally, fetch dependencies and create the src
directory where the Gleam code will live:
mix deps.get mkdir src
Let's Write Some Gleam
With all the setup in place, we can now start writing our business logic in Gleam! In the first iteration, we
will skip the waitlist functionality, but we will still create a system that checks if enrollment is possible. Let's start
with a src/enrollment.gleam
file. src
is the directory at the top level of the project, where mix_gleam
expects
you to keep your Gleam code.
We will put the following code in the file:
pub type Student { Student(id: Int, age: Int) } pub type Course { Course(id: Int, min_age: Int, seats: Int, seats_used: Int) } pub type RejectionReason { NoSeats AgeRequirementNotMet } pub type EnrollmentDecision { Enrolled Rejected(reason: RejectionReason) } pub fn enroll(student: Student, course: Course) -> EnrollmentDecision { case student.age >= course.min_age { False -> Rejected(AgeRequirementNotMet) True -> case course.seats > course.seats_used { False -> Rejected(NoSeats) True -> Enrolled } } }
There are quite a few things to unpack here, so let's take a quick crash course on Gleam.
What's Happening Here?
In the first part, we define a bunch of types. Types are the heart of typed languages. Here we need a Student
,
a Course
, an EnrollmentDecision
, and a RejectionReason
.
Taking a Student
declaration, we define not only a type itself but also its constructor, a Student
with two arguments: id
and age
. You can note that we don't include the name
here, even though we defined it
before in the Elixir schema definition. The name is just side-data. We don't use it for anything related to
business logic (unless you have a requirement, such as that only people with names starting
with E can take a course).
A type like RejectionReason
defines two constructors, which we can basically read as no seats left or the age requirement not being met.
At the end of the code block, we define an actual enroll
function. It takes a student and a course, and using
a case
statement, makes some decisions about the pending enrollment command. case
is the only flow control
structure in Gleam (there is no if
, for example). In this example, we first check the age requirement, and if it's met,
we check if seats are left.
With that code ready, we can now write a failing unit test. Let's add a student_roll_test.gleam
file
in the test
directory (this is a naming convention that mix_gleam
expects). In that file, add the following:
import enrollment.{Course, Enrolled, Student} import gleeunit pub fn main() { gleeunit.main() } pub fn enrolled_test() { let course = Course(id: 1, min_age: 21, seats: 10, seats_used: 9) let student = Student(id: 10, age: 20) let assert Enrolled = enrollment.enroll(student, course) }
There's a bit of boilerplate here, but in essence, we create a test function that builds a course with id = 1
,
min_age = 21
, seats_limit = 10
and seats_taken = 9
.
Then, we create a student who is 20 years old
(therefore too young to enroll). Finally, we try to enroll them. On running mix gleam.test
, you get the error:
Running student_roll_test.main F Failures: 1) enrollment_test.enrolled_test: module 'enrollment_test' #{function => <<"enrolled_test">>,line => 12, message => <<"Assertion pattern match failed">>, module => <<"enrollment_test">>, value => {rejected,age_requirement_not_met}, gleam_error => let_assert} location: enrollment_test.enrolled_test:12 stacktrace: enrollment_test.enrolled_test output: Finished in 0.011 seconds 1 tests, 1 failures
This is good, but not great. Let's improve the output by using gleeunit/should
, a library that makes the
unit testing experience better in Gleam:
import enrollment.{Course, Enrolled, Student} import gleeunit import gleeunit/should pub fn main() { gleeunit.main() } pub fn enrolled_test() { let course = Course(id: 1, min_age: 21, seats: 10, seats_used: 9) let student = Student(id: 10, age: 20) enrollment.enroll(student, course) |> should.equal(Enrolled) }
Now, the output is much more readable:
Running student_roll_test.main F Failures: 1) student_roll_test.enrolled_test: module 'student_roll_test' Values were not equal expected: Enrolled got: Rejected(AgeRequirementNotMet) output: Finished in 0.011 seconds 1 tests, 1 failures
Now, let's fix the test by calling it using a student who is old enough, and we will have a pass:
Running student_roll_test.main . Finished in 0.012 seconds 1 tests, 0 failures
Now that we have the first test figured out, we should write some more tests to cover interesting branches in the code. But one important question still lingers: How do I call this from my Phoenix project?
Calling Gleam from Elixir
Let's start working on the Phoenix side of our project. Go to lib/student_roll/enrollment.ex
and define two
functions related to the enrollment process:
def enrolled?(course_id, student_id) do from(e in Enrollment, where: e.student_id == ^student_id and e.course_id == ^course_id and is_nil(e.waitlisted_at)) |> Repo.exists?() end def enroll(course_id, student_id) do student = get_student!(student_id) course = get_course!(course_id) # some logic should go here %Enrollment{} |> Enrollment.changeset(%{student_id: student.id, course_id: course.id}) |> Repo.insert() end
We will write a test checking the enrollment logic:
describe "enrollment" do import StudentRoll.EnrollmentFixtures test "enroll a student" do birthday = DateTime.utc_now() |> DateTime.add(-18 * 365, :day) student = student_fixture(%{date_of_birth: birthday}) course = course_fixture(%{min_age: 22}) assert {:ok, _} = Enrollment.enroll(course.id, student.id) assert Enrollment.enrolled?(course.id, student.id) end end
And it passes! But it should not. We created a student who is roughly 18 years old, but the course requires that the students be at least 22. This is because we did not plug our Gleam-written business logic into the mix. Let's fix this now.
Calling compiled Gleam modules looks to Elixir the same way as Erlang modules. You invoke modules by prepending :
to the module name. In our case, this will be :enrollment
. We will also have to pass something that Gleam can interpret as its Student
and Course
types. This is the boilerplate I mentioned earlier.
def enroll(course_id, student_id) do student = get_student!(student_id) course = get_course!(course_id) student_age = DateTime.utc_now() |> DateTime.to_date() |> Date.diff(student.date_of_birth) |> div(365) seats_taken = Repo.aggregate( from(e in Enrollment, where: e.course_id == ^course_id and is_nil(e.waitlisted_at)), :count ) case :enrollment.enroll( {:student, student.id, student_age}, {:course, course.id, course.min_age, course.max_students, seats_taken} ) do :enrolled -> %Enrollment{} |> Enrollment.changeset(%{student_id: student.id, course_id: course.id}) |> Repo.insert() {:rejected, reason} -> {:error, reason} end end
Our enroll
function is slightly inflated, so let's walk through it.
First, we need some more data. We calculate the student age in Elixir here and also fetch the number of already enrolled students. Second, we build a structure that Gleam will interpret as its type. These are just tuples, where the first element is the name of a type, followed by several fields required by the constructor.
# Gleam: Student(id: Int, age: Int) {:student, student.id, student_age} # Gleam: Course(id: Int, min_age: Int, seats: Int, seats_used: Int) {:course, course.id, course.min_age, course.max_students, seats_taken}
Finally, we call :enrollment.enroll
with these tuples. If we run the test now, we will get:
1) test enrollment enroll a student (StudentRoll.EnrollmentTest) test/student_roll/enrollment_test.exs:131 match (=) failed code: assert {:ok, _} = Enrollment.enroll(course.id, student.id) left: {:ok, _} right: {:error, {:rejected, :age_requirement_not_met}} stacktrace: test/student_roll/enrollment_test.exs:136: (test)
This is exactly what we wanted to have! The test does not pass because the student is too young.
pub type RejectionReason { NoSeats AgeRequirementNotMet } pub type EnrollmentDecision { Enrolled Rejected(reason: RejectionReason) } # in Elixir: # :enrolled | {:rejected, :age_requirement_not_met} | {:rejected | :no_seats}
We've made the first step. We called the Gleam code from Elixir, got the results back, and interpreted them back into Elixir
idioms (a result tuple of {:ok, term()} | {:error, term()}
).
We should now write any remaining tests we want to run in Elixir. Remember that at this point, the enrollment process is thoroughly unit tested in Gleam by Gleeunit, so perhaps we don't need to duplicate all the cases — only the ones that have consequences in Elixir (but this is up to you and your testing strategy).
After this, we can refactor the enroll
function to look nicer and more reader-friendly.
defp get_gleam_student!(id) do student = get_student!(id) student_age = DateTime.utc_now() |> DateTime.to_date() |> Date.diff(student.date_of_birth) |> div(365) {:student, id, student_age} end defp get_gleam_course!(id) do course = get_course!(id) seats_taken = Repo.aggregate( from(e in Enrollment, where: e.course_id == ^id and is_nil(e.waitlisted_at)), :count ) {:course, course.id, course.min_age, course.max_students, seats_taken} end def enroll(course_id, student_id) do course = get_gleam_course!(course_id) student = get_gleam_student!(student_id) case :enrollment.enroll(student, course) do :enrolled -> %Enrollment{} |> Enrollment.changeset(%{student_id: student_id, course_id: course_id}) |> Repo.insert() {:rejected, reason} -> {:error, reason} end end
This looks better. You could even hide get_gleam_student!/1
and get_gleam_course!/1
in a module called,
for example, GleamTypes
. This way, other contexts that potentially call these structures in Gleam will have them readily
available.
But that's not all. We still have some work to do.
Implementing the Waitlist
If you recall, our initial requirements also included a waitlist.
This is missing in the implementation above; now is the time to fix it. This will allow you to see how changing the code in both Gleam and Elixir works. I strongly recommend you change the logic in Gleam first, unit-test it, and only then, write the Elixir proxy code (although if you fancy some strict TDD, you can start with some red Elixir tests too).
But before diving into the code, let's ask ourselves one design question: How should we represent the waitlist?
Should it be a number (waitlist_size
) or maybe a list of students? This is the classic question in functional design
and probably deserves a separate article to address it fully.
In our project, for already-signed-in students, we just pass a number of them, not a list of them. This time, we will model the waitlist as an actual list to make things more interesting.
First, we need to change our domain model by extending the Course
type:
pub type Course { Course( id: Int, min_age: Int, seats: Int, seats_used: Int, waitlist: List(Student), max_waitlist_size: Int, ) }
And then the list of enrollment decisions:
pub type EnrollmentDecision { Enrolled Rejected(reason: RejectionReason) Waitlisted }
After that, we have to adjust our existing Gleam tests because now they fail with the following message:
error: Incorrect arity ┌─ /home/user/dev/student_roll/_build/dev/lib/student_roll/test/student_roll_test.gleam:10:16 │ 10 │ let course = Course(id: 1, min_age: 21, seats: 10, seats_used: 9) │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Expected 6 arguments, got 4 This call accepts these additional labelled arguments: - max_waitlist_size - waitlist
And we also have to modify the Elixir glue code that calls Gleam:
defp get_gleam_student!(id) do get_student!(id) |> to_gleam_student() end defp to_gleam_student(student) do student_age = DateTime.utc_now() |> DateTime.to_date() |> Date.diff(student.date_of_birth) |> div(365) {:student, student.id, student_age} end defp get_gleam_course!(id) do course = get_course!(id) seats_taken = Repo.aggregate( from(e in Enrollment, where: e.course_id == ^id and is_nil(e.waitlisted_at)), :count ) waitlist = from(e in Enrollment, where: e.course_id == ^id and not is_nil(e.waitlisted_at)) |> Repo.all() |> Enum.map(&to_gleam_student/1) {:course, course.id, course.min_age, course.max_students, seats_taken, waitlist, course.waitlist_size} end # the enroll/2 function stays the same
Now let's write a failing test in Gleeunit:
pub fn waitlist_exceeded_test() { let course = Course( id: 2, min_age: 0, seats: 10, seats_used: 10, max_waitlist_size: 2, waitlist: [ Student(id: 1, age: 22), Student(id: 2, age: 13), Student(id: 3, age: 54), ], ) let student = Student(id: 4, age: 22) enrollment.enroll(student, course) |> should.equal(Rejected(NoSeats)) } pub fn waitlisted_test() { let course = Course( id: 2, min_age: 0, seats: 10, seats_used: 10, max_waitlist_size: 2, waitlist: [], ) let student = Student(id: 4, age: 22) enrollment.enroll(student, course) |> should.equal(Waitlisted) }
One of these tests will accidentally pass (because we reused the NoSeats
rejection reason, which might be a dubious choice).
But the other one fails. Let's fix it now by actually implementing the waitlist. We need to import the gleam/list
package on top and change the branch which previously resulted in NoSeats
:
import gleam/list # ... pub fn enroll(student: Student, course: Course) -> EnrollmentDecision { case student.age >= course.min_age { False -> Rejected(AgeRequirementNotMet) True -> case course.seats > course.seats_used { False -> case list.length(course.waitlist) >= course.max_waitlist_size { True -> Rejected(NoSeats) False -> Waitlisted } True -> Enrolled } } }
The final part will be done in Elixir. We have decided that a person should go to the waitlist, but now we have to persist it. Again, let's start with a test:
test "waitlist a student" do student1 = student_fixture() student2 = student_fixture() course = course_fixture(%{max_students: 1, waitlist_size: 10}) Enrollment.enroll(course.id, student1.id) Enrollment.enroll(course.id, student2.id) assert Enrollment.waitlisted?(course.id, student2.id) end
Then we need the waitlisted?
function:
def waitlisted?(course_id, student_id) do from(e in Enrollment, where: e.student_id == ^student_id and e.course_id == ^course_id and not is_nil(e.waitlisted_at) ) |> Repo.exists?() end
Finally, we implement the infrastructure part, persisting the waitlist in the database. In the
enroll
function's case
statement, we need to handle the :waitlisted
return type case:
:waitlisted -> %Enrollment{} |> Enrollment.changeset(%{student_id: student_id, course_id: course_id, waitlisted_at: DateTime.utc_now()}) |> Repo.insert()
All the tests pass now. We have, therefore, successfully implemented a waitlist function in our Gleam/Elixir application!
Looking at the requirements, we still have some way to go. We haven't touched on canceling an enrollment (when a user has successfully enrolled in the past but is no longer interested). Hint: this handles moving a person from a waitlist to a regular enrollment, so we at least need this type as a return value:
pub type CancellingEnrollmentDecision { Cancelled CancelledWithWaitlistProcessed(Student) }
There's also a big topic that can be explored further but was not even covered in our requirements: how we should handle uniqueness. Everyone should be able to enroll in a given course only once, and they should not be able to cancel if they are not enrolled. There are at least three ways to model that behavior between Gleam and Elixir, but that's a topic for a separate article.
Why Did We Do It Again?
Everything we've done here could, of course, all have been written just in Elixir. The code would be shorter and in one language. In most cases, splitting the responsibilities between Elixir and Gleam probably would be a questionable choice. However, I personally think it's something worth considering if:
- You like to model your application with types and are missing this from Elixir.
- You expect your business logic to grow in complexity and want guarantees from a statically typed language to help you manage that complexity.
- You want a strong separation between pure business logic and stateful application code.
If any of these are true for you, consider this arcane-looking setup. In any case, it's good to be aware that it is possible.
Wrapping Up
I have shown you how to call Gleam from an Elixir application and how to interpret the results. We wrote tests in both languages and some glue code.
Happy coding, whether you choose to do it only in Elixir or you also make use of Gleam!
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!