Elixir Programming Introduction - Complete Tutorial!

Timestamps

  • - Intro
  • - Why use Elixir
  • - Installation and Setup
  • - Intro to Mix
  • - Overview of Elixir Files
  • - Running and Elixir Script
  • - Compiling Elixir
  • - Running an Elixir Application
  • - Intro to Hex
  • - Basic Types
  • - Creating a Variable
  • - Module Attributes
  • - Atoms and Strings
  • - Strings
  • - More about Hex
  • - Numbers
  • - Compound Types
  • - Dates and Times
  • - Example App - Time Until the New Year
  • - Tuples
  • - Lists and Loops
  • - Mapping
  • - Structs
  • - Building a Guessing Game - Taking User Input
  • - if Statements
  • - Random Number
  • - Docs and Arity
  • - Cases and Errors
  • - Case
  • - Lists
  • - Append and Prepend
  • - Comprehensions with Conditions
  • - Functional Programming
  • - Defining Custom Functions
  • - What's Next?

I started using Elixir when it was basically a requirement for a job, so I learned as much as I could about it and then planned that material in to this video.

I'm coming at this with prior experience in other languages and I imagine a lot of you are too, so there will be times where I compare Elixir to something you already know. If you're brand new that's fine, you can totally start here.

Why Elixir

Elixir was designed from the beginning for building scalable applications and resilient applications, ones that can keep working even when things go wrong. It's a functional programming language, and we'll see more of what that means as we go.

It's not the most popular language out there, but it seems to be growing, and the people who do pick up Elixir and Phoenix for web development really love it. According to the 2023 Stack Overflow survey Phoenix is the most admired web framework, more developers would choose to work with it again than react, node.js, or nextjs. Then you look at the most POPULAR frameworks list and Phoenix is way down at the bottom. Pay is good too, Elixir is one of the top paying languages, and in the 2022 survey Phoenix was the highest on the top paying technologies list.

Just so we're all on the same page: Elixir is the programming language, Phoenix is a framework built on top of it for web applications. Fault tolerance is built in, you can have parts of your system reset and keep the application going. Other languages can do these things too, this was just the focus for Elixir, so it's much stronger in those areas.

Here's a snippet from the Elixir homepage to give you a taste:

"Fun to learn, delightful to ship"
|> String.downcase()
|> String.replace(",", "")
|> String.split()
|> Enum.frequencies()

We're basically piping values through functions. If you're brand new that probably doesn't look meaningful yet, so let's start from the complete beginning.

Installation and Setup

I'm on Mac so I'm going to use Brew:

Bash
brew install elixir

If you need Brew first, that's a separate package manager, grab the install line from https://brew.sh and paste it in your terminal.

If you're on Windows you can't use Homebrew, so head to https://elixir-lang.org/install.html, which splits the instructions out by operating system. On Windows you run the Erlang installer and then a version of Elixir.

Once that's done we should be able to invoke iex:

Bash
iex

This is the interactive Elixir environment, very similar to the Python repl or the node repl. You type things and they evaluate immediately. A quick hello world:

"hello " <> "world"

Hit enter and you get "hello world" back. The <> operator combines two strings. Obviously you didn't have to do it that way, you could just type the whole string, I just wanted to show a very basic operation.

You can do basic coding in the interactive session but anything more complex gets complicated doing it all from the terminal, so I personally like to work from files. Exit with Ctrl+C twice, then:

Bash
mkdir elixir
code elixir

From within VS Code go to extensions and search Elixir. You'll want ElixirLS for language support. I also installed Elixir Formatter, which basically invokes mix format for us so the indentation stays consistent. Those are the two I'd recommend.

Running an Elixir Script

Create a file called intro.exs:

IO.puts("Hello world from Elixir")

Open a new terminal and run it:

Bash
elixir intro.exs

You'll see Hello world from Elixir.

You'll also see .ex files around. The dif is that .ex is for compiled code and .exs is for scripted, interpreted code.

Intro to Mix

mix is the build tool for Elixir. It'll let us generate new projects, compile them, and run them. Type mix help and you'll get a big list, but the ones you'll actually use early on are mix new, mix compile, mix test, and mix run.

Let's generate a project:

Bash
mix new example

That creates a directory called example with quite a bit more than a single .exs file in it. Open that directory in VS Code (File > Open) so we're working inside the project.

So that's three approaches I've shown you now: iex from the terminal, a single Elixir file, and a full Elixir project. From here on we'll mainly work inside the project.

Overview of Elixir Files

There's a lot of junk in a generated project, so let's jump to the point:

  • lib/ contains our main code
  • test/ contains software tests
  • .formatter.exs describes how things should be formatted
  • .gitignore for a git repo
  • mix.exs describes our project and its dependencies
  • README.md describes the project, information only
  • _build/ has our dependencies and our built code, you can pretty much ignore it

The one we're going to work with is lib/example.ex, named after the project name we passed to mix new:

defmodule Example do
  @moduledoc """
  Documentation for `Example`.
  """

  def hello do
    :world
  end
end

defmodule defines a module with that name, and do indicates the start. You can kinda think of do as a left curly brace in other languages, and you close it with end at the bottom. The module is a namespace to group all of our code together.

def defines a function. All this one does is return :world, which is an example of an atom. It works kind of like a string, just slightly different, and we'll see more of that later.

Compiling Elixir

Let's run it:

Bash
mix compile

Then start an interactive session that loads our project:

Bash
iex -S mix

Now we can invoke functions in our project:

Example.hello

That gives us :world. The second way is to close out of iex and run:

Bash
mix run -e "Example.hello"

When you do this you'll notice you don't get a response in the terminal. What's going on? The interactive session automatically shows return values, and running the file directly does not. So if we want output when we run it directly, we need to print the value:

def hello do
  IO.puts(:world)
end

Recompile and run again and you get world in the terminal. That's the difference between a function that RETURNS a value and a function that PRINTS a value.

Running an Elixir Application

What I'm about to show you took me entirely too long to understand, so if you can wrap your mind around this it's going to save you a lot of pain. Learn from my mistakes here.

I was trying to figure out the proper way to run Elixir code. There's a compiling step, which takes your source and converts it to some intermediary language that then gets executed, and Elixir can also interpret, reading through the code as it's executed. Of those two I generally prefer compiling, so I'd be working in an example.ex file, which seems to be the recommended route since that's what gets generated for you.

So I was invoking an individual function and thinking of that as our main function. But I was curious if I could just define a starting point and execute the project. So I tried:

Bash
mix run

It compiles the file but nothing is output. Okay, so what if I put the call directly in the module body, after the function is defined?

defmodule Example do
  def hello do
    IO.puts(:world)
  end

  hello()
end

mix run and there we go, we get world. I figured it out. Then I ran it again and nothing happened.

Unlike other languages, code defined directly inside the module is executed when the code is COMPILED, not when the code is RUN. If I change the code, mix run recompiles and I see the output that one time. Since compilation is now done, the next mix run doesn't compile again and we see nothing. Pretty important to understand.

I found a Stack Overflow question describing this exact problem, someone only saw their output the first time and any consecutive runs showed nothing. The answer says lines in the file evaluate at compile time, and if you want something to start when you start your app you need to specify that in your mix.exs. There's a good Elixir Forum thread on compile time vs run time if you want to go deeper on when each piece of your code actually gets evaluated.

So it seemed like I was stuck with mix run -e "Example.hello" forever. But there's one more thing we can do. We can define at the application level what should run by default, and that's in mix.exs. Extend the application function with mod:

def application do
  [
    extra_applications: [:logger],
    mod: {Example, []}
  ]
end

mod specifies the module that acts as the entry point for your application. It expects a tuple, that's what the curly braces are, the first thing is the module and the second is any arguments passed to the start function. We're not passing anything, so it's an empty list.

That's step one. Next, inside our module we define a start function:

defmodule Example do
  use Application

  def start(_type, _args) do
    IO.puts("Hello world")

    Supervisor.start_link([], strategy: :one_for_one)
  end
end

Now:

Bash
mix run

We get Hello world, and if we run it again we still get Hello world. We don't have to specify a function to call anymore, we have an automatic default starting location.

A few things going on there. The start function has two parameters and we prefixed them with an underscore. In Elixir that's how we say hey, we're defining these to describe the structure of the function but we're not actually using them. When we do that we don't get any warnings about unused variables. Remove the underscores and mix will give you two warnings.

Then that weird Supervisor.start_link line. A supervisor is a process that supervises other processes, known as child processes, and it has the capability to restart any of those children. This is one of the fundamental capabilities of Elixir that allows for the scalability and resiliency I talked about at the beginning. The list is the children, we currently have none, and :one_for_one means if a child process crashes only that process gets restarted.

That line isn't really doing anything interesting for us. We're doing it to fulfill a rule, which is that start has to return a supervision tree. Yada yada, a bunch of technical jargon, basically you just have to have this line here for the new application setup to work.

Going forward we can put our code right there or in a function that gets called from there.

Intro to Hex

As you build out applications, instead of always reinventing the wheel you can pull in other people's libraries. For that we use hex, which is Elixir's package management software, the equivalent of npm for node or pip for Python.

Bash
mix local.hex

I already had it so I just said yes to replace. Then you can search for pretty much any package on https://hex.pm. The one I'm interested in is uuid, which generates unique IDs. Go in there and look at the total downloads and downloads in the last 90 days, just to make sure you're downloading something legit.

Copy the version and drop it in the deps list in mix.exs:

defp deps do
  [
    {:uuid, "~> 1.1"}
  ]
end

Putting it in the list doesn't install it, you have to run:

Bash
mix deps.get

Now we can use it:

alias UUID

IO.puts(UUID.uuid4())

alias is essentially how you'll import an external package. Run mix and you get a unique identifier. Probably a pretty silly example by itself, but it shows the end to end process of adding a package and using it.

A main Function

At this point we have a decent understanding of starting a project, running it, and pulling in dependencies. Now I want to talk about the actual language.

Let's set up somewhere to put our examples. Down in start I'll just invoke a function I'll call main:

defmodule Example do
  use Application

  def start(_type, _args) do
    main()

    Supervisor.start_link([], strategy: :one_for_one)
  end

  def main do
    IO.puts("example output")
  end
end

I like this setup, the name clearly indicates where we're starting, and now we can basically ignore everything above main, which is probably what I'll end up doing.

Creating a Variable

What might be called variable assignment in other languages is called binding in Elixir:

x = 5
IO.puts(x)

Run mix and we get 5. This can be rebound:

x = 5
x = 10
IO.puts(x)

We get 10, plus a warning that variable x is unused. That's a little confusing because we're definitely using x, but it's referring to that FIRST binding, we're immediately replacing that value without ever using it. Warnings can technically be ignored but they're a good indication you might be doing something you didn't mean to, and in this case you could just delete the first line and get the same result.

Module Attributes

If you come from JavaScript you might be used to const x = 5, saying hey, this value cannot change. There's no easy equivalent in Elixir, but there is a workaround, which is defining something at the module level. These are called module attributes:

defmodule Example do
  @x 5

  def main do
    IO.puts(@x)
  end
end

We get 5. If we try to give it a new value inside the function with @x = 10 we get a problem, "the pattern can never match the type", which is the way assignment works in Elixir. That's basically a way of saying you can't give this a new value. So that's how you can implement the concept of const.

Atoms and Strings

We've seen a little of both. What's the difference, and when should you use which?

IO.puts(:hello)

Run it and we get hello in the terminal, without the colon. So this is basically an alternative to a string, you could do that or you could do IO.puts("hello") and get the same output.

Atoms are constant values where the name and the value are the same thing. They're ideal for a small set of words used throughout the application that indicate some meaning. They're not really fit for arbitrary values from user input. So anytime you're hardcoding a value it makes sense to use an atom, but if you're getting a name from the user, that's a string.

When it comes to performance, if you're using static values it makes more sense to use an atom. All the atoms get defined for your application and each one takes up some area of memory, so comparing for equality is as simple as checking the location in memory, whereas comparing two strings means checking each character. This is likely not an optimization you have to worry about unless you have some specific application doing a ton of comparisons.

You can also make atoms with multiple words by keeping everything in quotes and prefixing with a colon:

:"not a member"

Long story short, it doesn't really matter either way for most things.

Let's build off this with an example, a membership status that decides if you get in to some executive lounge at a gym or an airport:

name = "Caleb"
status = :gold

if status === :gold do
  IO.puts("Welcome to the fancy lounge #{name}")
else
  IO.puts("Get lost")
end

You substitute a value in to a string with the pound sign and curly braces, that's string interpolation. Run it and we get Welcome to the fancy lounge Caleb.

You'll get a warning that the pattern can never match the type, because status is a constant :gold so the compiler knows it's always going to hit that path and the else is pointless. We can get rid of that warning by picking a random value instead, since the compiler can't know it ahead of time:

status = Enum.random([:gold, :silver, :bronze])

Now either path can be hit. I ran this a bunch of times and kept getting Get lost, I'm apparently very unlucky, but eventually I got in. I guess if you try enough times you'll eventually get in.

We used three equal signs there to check equality. You can also use two equal signs, which is slightly less strict, in a similar fashion to the way JavaScript has == and ===.

An if is one way to check values, but there's also a case:

case status do
  :gold ->
    IO.puts("Welcome to the fancy lounge #{name}")

  :"not a member" ->
    IO.puts("Get subscribed")

  _ ->
    IO.puts("Get out bro")
end

The _ at the bottom is the default case. Note that :"not a member" needs to match exactly, so it also has to be one of the values in the list we're randomly picking from for that branch to ever get hit.

Strings

Now some more detail on strings. First off, escape characters. You can embed these inside a string and they'll be interpreted as something else, the common ones being a new line and a tab:

IO.puts("this\nis\na\nmessage\n")
IO.puts("after")

Each word lands on its own line, then the trailing \n brings us down, and IO.puts automatically adds a new line at the end too, so we get two blank-ish lines before after.

This also matters if you're trying to display code. Say you want to show what interpolation looks like without actually doing it, you escape the pound sign:

IO.puts("interpolation looks like \#{}")

The other one is Unicode. Prefix a character with a question mark and you get its code point:

IO.puts(?a)

You don't even need quotes for this. We get 97. Change it to ?b and we get 98. Every character in Unicode gets a unique number.

More about Hex

When you look these up they tend to be in hexadecimal. Say you have the decimal number 275, you can convert it with a conversion calculator and get the hex value 113. Then you can use https://codepoints.net to look up any individual hex value, so 0113 gives you the Latin small letter e with macron.

Not to get too distracted, but a brief moment on hex. It's a different way to represent the same information. The decimal number 275 is written 113 in hex, and the reason the number looks smaller is that every digit gets one of 16 values, 0 through 9 or a through f. So you count up to 9, then 10 is a, 11 is b, 12 is c, 13 is d, 14 is e, 15 is f, and to represent 16 you go back to a 1 and add another hex place. I have other videos on hex you can check out if you wish.

Numbers

a = 10
b = 3.0

IO.puts(a + b)

One is an integer and the other is floating point. A float will have something after the decimal place even if that number is zero. This gives us 13.0. Change b to 3 and we get 13. So the type of the output depends on the types of the inputs, two integers in gives an integer out, one or both floating point gives floating point.

It's worth noting Elixir is dynamically typed, in contrast to a static language where all the variables have a type. So you can do this:

a = a + 5.0
IO.puts(a)

That gives 15.0. We originally assigned an integer and now it's a float. Elixir is chill about it. This makes it very fluid and potentially easy to work with, you can often combine types without a bunch of compiling errors, but it does risk runtime issues when some types don't work together and your program crashes. So just pay attention, because what looks like the same type is actually two different types.

Division is one of those interesting ones:

a = 10
b = 3

IO.puts(a / b)

We get 3.3333333333333335. Most people would look at that and think yeah that makes sense, but if you're coming from C++ two integers with a division symbol would typically crop off the value and give you just 3 with integer division, and you'd have to fix it by casting one of them to a float. In Elixir there's no real concept of integer division vs float division with /, it's always going to give a floating point number. So 10 / 5 gives 2.0 even though that could be an integer.

The other difference is there's no float and double, we just have float, but the float is 64 bit which is typically what a double is elsewhere. In C++ I'd typically say don't use float, use double. In Elixir you only have float, but it acts as a double, so it's totally fine.

Regardless of 32 or 64 bit there are potential issues with floating point numbers. You can see this if you expand a number out long enough:

:io.format("~.20f~n", [0.1])

0.1 looks great and then eventually it goes off track and we get 0.10000000000000000555. Certain numbers cannot be represented perfectly, this is as close as reasonably possible. That seems weird, but it's just how binary works. Each position in the fractional part of a binary number is a half, so the first is 0.5, then 0.25, then 0.125, and you combine those to represent numbers. Representing 0.1 is impossible, even with unlimited fractional places you still can't get there, and floats only have a certain number of values available. The same issue exists in decimal, try representing a third and you get 0.3 repeating.

That's enough on floating point, it's kind of super boring. Just be cautious, and if you need really high precision it might be better to work with an integer.

Some functions we can use with numbers:

IO.puts(Float.ceil(0.5, 1))

Float.ceil raises a number up to a specified precision. 1 here refers to one place after the decimal, so it's not really changing anything and we get 0.5. Drop the precision argument and round to a whole number and we get 1.0.

Be cautious, because this brings up the same floating point problems:

IO.puts(Float.ceil(0.1, 1))

That gives us 0.2, which is a little strange because 0.5 stayed the same. But 0.5 is a number that can be perfectly represented in binary and 0.1 is not, it's really 0.1 with a bunch of zeros and then some fives, so when we round up to a single decimal place we get 0.2.

That's why I generally prefer to use integers, and a lot of the time you can use integers for what would make sense as fractional numbers. Money, for example. Instead of $20.50 you have a variable cents holding 2050 and divide by 100 whenever you need the dollar value. Way safer and you don't have to worry about these weird scenarios where numbers change on you.

Integers have functions too:

IO.puts(Integer.gcd(25, 10))

Greatest common divisor, we get 5. I'm not going to go through every single function, you can do that as an exercise if you feel it's valuable. Generally I just want to know these functions exist so I can refer to them when I need something, and understand how to call them.

Compound Types

Now compound types, which are types made up of multiple basic types. We're going to talk about dates and times, tuples, lists, structs, and maps.

Dates and Times

time = Time.new(16, 30, 0)
IO.puts(time)

Run this and we get an error, because these compound types that contain multiple values can't just be printed whole. There's an easy way around it for development purposes:

IO.inspect(time)

IO.inspect is a little different but works effectively the same way and gives you a representation that makes sense. I'll be using inspect for the rest of these types.

There's also a date:

date = Date.new(2025, 1, 1)
IO.inspect(date)

And a DateTime which combines both, taking a date, a time, and a time zone:

date_time = DateTime.new(date, time, "Etc/UTC")
IO.inspect(date_time)

Run that and we get an error, and if you look at the problems it says the function call will not succeed. That's about the type of function we're invoking. When we call new we have the option of new by itself or new with an exclamation mark, and if you look at the docs the bang version raises on errors, so it'll throw an exception and it could crash your program.

That's a way to get past the compiling error and say hey, trust that we're passing in something correct, or throw an error, no big deal. But to do it we have to do it for the time and the date too:

time = Time.new!(16, 30, 0)
date = Date.new!(2025, 1, 1)
date_time = DateTime.new!(date, time, "Etc/UTC")

IO.inspect(date_time)

When you see an exclamation mark, just think warning, caution, this could throw an error.

You can also access individual pieces:

IO.puts(date_time.year)

Time Until the New Year

Let's calculate how much time until the new year. Could be a fun little desktop widget, or you could put it on the back end of some API, whatever you want.

First get the date we're targeting:

time = DateTime.new!(Date.new!(2024, 1, 1), Time.new!(0, 0, 0), "Etc/UTC")

You can generate both at once like that. Update the year as needed if you're watching this next year. I guess it wouldn't be next year for you but you know what I mean.

Then subtract one from the other:

time_till = DateTime.diff(time, DateTime.utc_now())
IO.puts(time_till)

That's how long we have until the new year, in seconds. To split it out in to components we'll do a little math. There might be an easier way to do this but it isn't that hard.

For days I don't want floating point division, because then I wouldn't have any leftover seconds for the minutes and seconds. So we use div for integer division, and instead of a division symbol you separate the arguments with a comma:

days = div(time_till, 86_400)
IO.puts(days)

You can use underscores in numbers, they don't interrupt the value, they just make it more readable. To get the leftovers you use rem for remainder:

IO.puts(rem(time_till, 86_400))

Same idea for hours, minutes, and seconds:

days = div(time_till, 86_400)
hours = div(rem(time_till, 86_400), 60 * 60)
mins = div(rem(time_till, 3600), 60)
seconds = rem(time_till, 60)

Anything that's not absorbed by days is going to be left over in that remainder, and anything smaller than 3600 falls in to the hours remainder, which we divide by 60 to get minutes.

Last thing, format a nice string:

IO.puts("Time until New Year: #{days} days #{hours} hours #{mins} minutes #{seconds} seconds")

I checked it against the clock, November 27th, about 34 days, 1 hour because we're looking at UTC, yeah that makes sense.

Tuples

Tuples let us store multiple values in a single variable. A tuple is slightly different in concept than a list, you can think of a tuple kind of as a static array, it has a set number of elements that you define when you create it. A list is more dynamic, think of it as an array list, or a vector in a language like C++.

I'll typically use a tuple if I'm storing a few values and just want to group them together:

memberships = {:bronze, :silver, :gold}
IO.inspect(memberships)

You're not completely stuck with those values, you can build one up:

memberships = {:bronze, :silver}
memberships = Tuple.insert_at(memberships, tuple_size(memberships), :gold)

💡 Tip[!tip]
On screen I type Tuple.append(memberships, :gold). That got deprecated in Elixir 1.14, so it still runs but the compiler warns at you now, and the docs point you at Tuple.insert_at/3 instead, which is what's in the block above. Don't let it throw you when the video and the notes don't match here, they do the same thing. Either way you get back a NEW tuple that you have to reassign, tuples don't get modified in place.

Let's do the same with prices, and do some math on them:

prices = {5, 10, 15}
average = Tuple.sum(prices) / tuple_size(prices)
IO.puts(average)

Note that sometimes the functions are standalone, like tuple_size, and other times they're attached to the type, like Tuple.sum. We get 10.0, the average of 5, 10, and 15.

Those are the categories. We could also use a tuple to store an individual instance:

user1 = {"Caleb", :gold}

That shows you can mix types within a tuple, we happen to have a string and an atom here, you're not required to keep them the same.

To read values back out, elem takes the tuple and an index, which starts at zero:

IO.puts("average price for #{elem(memberships, 0)} #{elem(memberships, 1)} #{elem(memberships, 2)} is #{average}")

We could do something similar for the user, but I want to show you a different way with destructuring:

{name, membership} = user1
IO.puts("#{name} has a #{membership} membership")

Assignments in Elixir are actually pattern matching. We're taking user1 and breaking it up in to this structure, splitting it in to name and membership. If it matches a two item tuple then it's a successful match and the values get bound. Pretty complicated way of saying this is how we break out the user's name and membership status.

Lists and Loops

Say we want a few more users:

user1 = {"Caleb", :gold}
user2 = {"Kayla", :gold}
user3 = {"Carrie", :silver}

We could copy those two lines three times, but at some point it makes sense to introduce loops. If you find yourself doing the same thing for multiple individual variables there's a good chance you could structure this better with a list and iterate through it. You can kinda think of a list as a more dynamic tuple, ideal when you're not sure how many elements you'll have:

users = [
  {"Caleb", :gold},
  {"Kayla", :gold},
  {"Carrie", :silver}
]

To describe some code we want to run for each element we use a loop:

Enum.each(users, fn {name, membership} ->
  IO.puts("#{name} has a #{membership} membership")
end)

The first argument is the enumerable, which is basically a word for something you can iterate through. The second is a function that takes an individual element and does something with it. We define a function inline with fn, destructure the tuple right in the parameter, then ->, then whatever we want to do, and you finish it off with end.

That replaces all those repeated lines, and now it's a lot more dynamic. Add {"John", :bronze} to the list and the rest of the code stays the same.

Mapping

The next structure is a map, a key value pair list where you can have multiple keys pointing at some value. Very useful for lots of computer science topics like algorithm optimization, if you were calculating factorials you might have the key be the number and the value be the factorial so you can look it up instead of recalculating every time. But I don't want to do anything with factorials here, no thanks.

We'll make a map for memberships instead. Use a percent sign and curly braces:

memberships = %{gold: 3, silver: 2, bronze: 1, none: 0}

Now down in the list we can choose a specific membership from that map:

users = [
  {"Caleb", memberships.gold},
  {"Kayla", memberships.gold},
  {"Carrie", memberships.silver},
  {"John", memberships.bronze}
]

Editor autocomplete will show you the keys, so it's a lot safer to choose a valid value. When you put the key there it looks up the associated value, in this case 3, and that's what actually gets used, so now we see Caleb has a 3 membership.

That works if you're using the numbers to decide what people are allowed to do, but for display it's confusing. The value can be anything you want, so since I'm using this map purely for display I'll just make the values atoms:

memberships = %{gold: :gold, silver: :silver, bronze: :bronze, none: :none}

Now the map is more a way to help me choose a valid value. We can do another map for prices:

prices = %{gold: 25, silver: 20, bronze: 15, none: 0}

And include the price in the output, using the membership as the lookup key with square brackets:

Enum.each(users, fn {name, membership} ->
  IO.puts("#{name} has a #{membership} membership paying #{prices[membership]}")
end)

Structs

A struct is a way to create your own types. It defines some structure and then you create instances of it. Structs are probably the most flexible and will likely be used if you're doing more complex custom structures, if for some reason a tuple, list, or map does not work for you.

Outside of our module we create another module:

defmodule Membership do
  defstruct [:type, :price]
end

defmodule User do
  defstruct [:name, :membership]
end

This describes the structure but is not an instance, think of it as a blueprint or a cookie cutter. Now let's actually create some:

gold_membership = %Membership{type: :gold, price: 25}
silver_membership = %Membership{type: :silver, price: 20}
bronze_membership = %Membership{type: :bronze, price: 15}
no_membership = %Membership{type: :none, price: 0}

Kind of similar to how we describe a map, except between the percent and the curly braces you put the type. Then the users, and we don't need that separate prices map anymore since the price is embedded in the membership:

users = [
  %User{name: "Caleb", membership: gold_membership},
  %User{name: "Kayla", membership: gold_membership},
  %User{name: "Carrie", membership: silver_membership},
  %User{name: "John", membership: bronze_membership}
]

The structure is different now, so the destructuring in our loop needs updated. To pattern match with a struct specifically you prefix it, and on the inside you use key value pairs:

Enum.each(users, fn %User{name: name, membership: membership} ->
  IO.puts("#{name} has a #{membership.type} membership paying #{membership.price}")
end)

membership is its own custom type now, so we reach in for membership.type and membership.price.

That's pretty much everything I wanted to cover on types. We covered a lot and I'm sure there's a million more things we could talk about, but I think that's good for now.

Building a Guessing Game

Let's take a break from the concepts and learn a little in an applied manner. A guessing game is one of my favorite things to do when learning a new language because it teaches how to get random numbers, how to get user input, and how to branch.

Start by hardcoding a correct answer and getting a value from the user:

correct = 8

guess =
  IO.gets("guess a number between 0 and 10: ")
  |> String.trim()

IO.puts("you guessed #{guess}, the correct was #{correct}")

The |> is the pipe operator, it pipes the output from the left in to the function on the right. This would basically be the equivalent of method chaining, just structured a little differently. String.trim removes any spaces at the beginning or end.

Piping to String.trim is important because the new line is going to be left in the input string. Skip that step and you'll see you guessed 5 with a new line character and then the rest of the string, which is a little strange. So whenever you're getting user input you'll very often see String.trim.

if Statements

Instead of printing what they guessed, let's check it:

if String.to_integer(guess) === correct do
  IO.puts("you win")
else
  IO.puts("you lose")
end

Without the String.to_integer this is close but not quite there, you type in 8 and it still says you lose, and you'll see a warning that it can never evaluate to true. That's because these need to be of the same type, the input is read in as a string and we're comparing against a number.

Random Number

Now let's generate a real random number:

correct = :rand.uniform(11) - 1

:rand.uniform(10) gives us a value between 1 and 10, so to get 0 through 10 we get a number between 1 and 11 and subtract one.

That function call with the colon looks a little different because we've been prefixing atoms with a colon. A colon can also be used to access Erlang modules. Elixir is built on the Erlang virtual machine, so we have access to Erlang's built-in functions, and sometimes you'll see calls prefixed with a colon like this. Same syntax as the atoms we've used, but this one is special in that it lets us invoke functions from the Erlang module called rand. Hover over it and you'll see it provides a pseudo random number generator.

Docs and Arity

Whenever you're working with a function you'll sometimes see /1 or /2 after it. That's the arity, the number of arguments. If you search to_integer in the Elixir docs you'll see to_integer/1 and to_integer/2, and for different types too, so String and List. There are many overloads taking different numbers of arguments, and they might work slightly differently. Converting to an integer, for example, lets you pass in the base as the second argument if you wanted a hex number.

Cases and Errors

Let's look at Integer.parse. This one has the ability to return various things, so we need to learn how to work with the tuple it gives back and how to deal with the error potential. This gets us more practice with cases and pattern matching, which is a big part of Elixir.

Adapt our pipeline to use parse instead of String.to_integer:

guess =
  IO.gets("guess a number between 0 and 10: ")
  |> String.trim()
  |> Integer.parse()

Now the if complains that it can never evaluate to true, because the value isn't direct anymore, it's nested inside a tuple. To get just the single element:

IO.puts(elem(guess, 0))

Let's look at the full tuple:

IO.inspect(guess)

Type in 5 and you get {5, ""}, the number and an empty string. Type in Caleb and you get :error. Type in 5th and you get {5, "th"}, the number and any extra data in the second position.

So there are three different structures, and we can case on those patterns:

case guess do
  {result, ""} ->
    IO.puts("parse successful #{result}")

  {result, _other} ->
    IO.puts("parse partially successful #{result}")

  :error ->
    IO.puts("something went wrong")
end

Notice inside a branch you can just use result instead of doing the elem call with an index. (I can never spell successful, it's two s's).

You might only care whether a number came back at all and not care about the leftovers, in which case you can ignore that second position with an underscore and collapse the first two branches:

case guess do
  {result, _} ->
    if result === correct do
      IO.puts("you win")
    else
      IO.puts("you lose")
    end

  :error ->
    IO.puts("something went wrong")
end

Obviously in a real game you'd remove the message printing the actual data, that's going to ruin it if you know the answer ahead of time, but it's really helpful for debug purposes.

Lists

We've talked about lists before, but now I want to go in to more detail, especially list comprehension. Understanding lists really matters once we get to functional programming and want to do something like apply a function to every element.

grades = [25, 50, 75, 100]

for n <- grades do
  IO.puts(n)
end

This loops through the data, each time binding a new value to n. That's a very basic comprehension. Right now all we're doing is printing, but we can modify the data and it'll actually return a new list:

new =
  for n <- grades do
    n + 5
  end

IO.inspect(new)

Say these are individual students' grades and we're feeling generous, we're going to add five to each person's grade out of the kindness of our heart. n + 5 seems weird by itself, like what are we doing with it, are we assigning it to something? What it does is put that in each element, all contained within a new list. So we get [30, 55, 80, 105].

Append and Prepend

A common thing is appending or prepending data:

new = new ++ [125]
new = new ++ [150, 175]
IO.inspect(new)

125 gets added at the end. You can do multiple at once, that's totally fine.

Putting something at the beginning is a little different, you build a new list with the value, a pipe symbol, and then the original list:

final = [5 | new]
IO.inspect(final)

Comprehensions with Conditions

Say we wanted all the even numbers in our final list. We build the loop, add a condition, and then do with what we want to return:

even =
  for n <- final, rem(n, 2) == 0 do
    n
  end

IO.inspect(even)

If the condition evaluates to true, that individual n gets added to the new list. Put true there instead and you get a completely matched list, every element ends up in the result. With the remainder check, anything odd gets dropped, so we're left with [30, 80, 150], anything ending in five was removed.

You could also do this with Integer.is_even:

require Integer

even =
  for n <- final, Integer.is_even(n) do
    n
  end

💡 Tip[!tip]
On screen I call Integer.is_even(n) with no require above it. Integer.is_even/1 is a macro, not a plain function, so it won't compile without require Integer, which is why that line is in the block above. It belongs at the top of your module with the other use and alias lines, I've just put it inline here so the example stands on its own.

This example isn't perfect for grades that could have fractional numbers, but it shows list comprehension with a condition. You can also alter what's returned, multiply by two, square it, whatever you want.

Functional Programming

Now some functional programming concepts, mainly passing functions to functions. We've seen a little of this already with Enum.each:

numbers = [1, 2, 3, 4, 5]

Enum.each(numbers, fn num ->
  IO.puts(num)
end)

each is an example of a higher order function, a function that takes another function passed in as an argument. In our case we're using an anonymous function, one we did not give a name and did not define separately, it's defined right there and passed to Enum.each.

But what if we have a function that already exists and we want to use it? Very similar, except I want to use Enum.map, which applies a function to each element in the list. Say we're given a list of numbers that are currently strings and we want to convert them:

numbers = ["1", "2", "3", "4", "5"]

result = Enum.map(numbers, &String.to_integer/1)
IO.inspect(result)

That & is called the capture operator, and it's really strange because it takes a non-anonymous function and essentially converts it to an anonymous function, which sounds like it doesn't make sense since it clearly does have a name. Basically just remember that if you ever want to pass an existing function as an argument you prefix it with the capture operator and you have to include the arity.

Run it and we see the values without quotes. This is a pretty common approach to process data, and you'll often see other functions like filter or reduce that also want a function as an argument.

Defining Custom Functions

Now I want to define my own function, not an anonymous one. Let's calculate the sum and average of those numbers. There's probably already a super easy function that does that, but this gets us more practice with tuples too:

def sum_and_average(numbers) do
  sum = Enum.sum(numbers)
  average = sum / Enum.count(numbers)

  {sum, average}
end

def, pretty much exactly the same syntax we have for main. The parameter is the name for the variable defined here, the argument is the name for the thing passed in. We return both values just by putting the tuple down at the bottom.

Let's create another one that doesn't return anything and instead just outputs some data:

def print_numbers(numbers) do
  numbers
  |> Enum.join(" ")
  |> IO.puts()
end

Enum.join converts an enumerable in to a string using a joiner as a separator. Now you can customize how they display, you're not limited to how IO.inspect shows them.

Last thing that I think would make this awesome is actually getting these numbers from the user:

def get_numbers_from_user do
  IO.puts("enter numbers separated by spaces")

  IO.gets("")
  |> String.trim()
  |> String.split(" ")
  |> Enum.map(&String.to_integer/1)
end

String.split is basically the opposite of join. These come in as strings so we process them through Enum.map with the capture operator. We don't have to specify a return, this isn't being assigned to anything so it's what gets returned.

And now main pulls it all together:

def main do
  numbers = get_numbers_from_user()
  print_numbers(numbers)

  {sum, average} = sum_and_average(numbers)

  IO.puts("the sum is #{sum}, the average is #{average}")
end

We destructure the result and it pattern matches perfectly to sum and average. First time I ran this I got an exception, small mistake of redundancy on my part, I was doing the Enum.map twice, once in get_numbers_from_user and once again in main. Removed the duplicate and it works.

Type in 10 15 20 25 and the sum is 70 and the average is 17.5.

What's Next?

That gives you an introduction to the Elixir programming language. If you're looking for what to do next, check out the follow-up video on Elixir Phoenix, which is the web development framework. It goes in to more details on the language but in a more applied fashion, so instead of concept after concept we actually get to use these concepts and build out something bigger.

Let me know what you thought of this video, the format, the length, and if there's anything I could do to improve the structure or how we progress through the content.