Output & Running
Hello, World
The smallest complete program in each language — the Elixir column is the whole file.
Option Strict On
Imports System
Module HelloWorld
Sub Main()
Console.WriteLine("Hello, World!")
End Sub
End ModuleIO.puts("Hello, World!")No module, no entry point, no imports. An Elixir script runs its expressions top to bottom.
Console.WriteLine becomes IO.puts, where IO is a module and puts a function in it — that Module.function shape is how everything in Elixir is called, because there are no objects with methods.String interpolation
Interpolation with a hash and braces, and a second printing function you will use constantly.
Option Strict On
Imports System
Module InterpolationDemo
Sub Main()
Dim name As String = "Ada"
Dim score As Integer = 42
Dim ratio As Double = 0.8756
Console.WriteLine($"Hello, {name}! Score: {score}")
Console.WriteLine($"Rounded: {ratio:F2}")
End Sub
End Modulename = "Ada"
score = 42
ratio = 0.8756
IO.puts("Hello, #{name}! Score: #{score}")
IO.puts("Rounded: #{:erlang.float_to_binary(ratio, decimals: 2)}")
IO.puts("Doubled: #{score * 2}")
IO.inspect([1, 2, 3])$"...{name}..." becomes "...#{name}...", with no prefix on the string, and any expression may go inside. There are no format specifiers: rounding is a function call. The one to learn early is IO.inspect, which prints the structure of any value and returns it unchanged — so it can be dropped into the middle of a pipeline to see what is flowing through without altering the result.Modules & Functions
Module and function both keep do ... end
The block structure is closer to Visual Basic than to anything C-shaped:
do opens and end closes.Option Strict On
Imports System
Module Greeter
Public Function Greet(name As String) As String
Return $"Hello, {name}"
End Function
Private Function Shout(text As String) As String
Return text.ToUpper()
End Function
Public Function LoudGreet(name As String) As String
Return Shout(Greet(name))
End Function
End Module
Module SyntaxDemo
Sub Main()
Console.WriteLine(Greeter.LoudGreet("Ada"))
End Sub
End Moduledefmodule Greeter do
def greet(name) do
"Hello, #{name}"
end
defp shout(text) do
String.upcase(text)
end
def loud_greet(name) do
shout(greet(name))
end
end
IO.puts(Greeter.loud_greet("Ada"))Module becomes defmodule and Function becomes def, both closed by end — no braces anywhere. Visibility is in the keyword rather than a modifier: def is public and defp is private. There is no Return: a function returns its last expression. Module names are CamelCase and everything else is snake_case, which is a convention the compiler will warn you about.Atoms
A constant named by itself, needing no declaration — you will meet these in the first five minutes.
Option Strict On
Imports System
Public Enum Status
Pending
Active
Closed
End Enum
Module AtomDemo
Sub Main()
Dim current As Status = Status.Active
Console.WriteLine(current)
Console.WriteLine(current = Status.Active)
Console.WriteLine(CInt(current))
End Sub
End Modulecurrent = :active
IO.puts(current)
IO.puts(current == :active)
IO.inspect(is_atom(current))
IO.inspect({:ok, "loaded"})
IO.inspect(%{status: :active})An atom is written
:name and its value is its own name. It is what a Visual Basic Enum member is reaching for, without declaring the enum first, and it is used everywhere: :ok and :error as result tags, :active as a state, and map keys via the %{status: :active} shorthand. true, false and nil are themselves atoms. Module names are atoms too, which is why they can be passed around as values.Comments and documentation
The comment character changes, and documentation becomes part of the compiled program rather than a comment.
Option Strict On
Imports System
Module CommentDemo
''' <summary>Doubles a number.</summary>
Function Twice(value As Integer) As Integer
' A line comment
Return value * 2
End Function
Sub Main()
Console.WriteLine(Twice(21))
End Sub
End Moduledefmodule Maths do
@moduledoc "Small arithmetic helpers."
@doc "Doubles a number."
@spec twice(integer()) :: integer()
def twice(value) do
# A line comment
value * 2
end
end
IO.puts(Maths.twice(21))' becomes #, and there is no block comment. '''<summary> becomes @doc, which is not a comment: it is a module attribute stored in the compiled artifact, readable by h Maths.twice in the shell and by the documentation generator. @spec declares types — checked by the dialyzer tool rather than by the compiler, which makes it the same kind of promise as a Python type hint.Nothing Ever Changes
Data cannot be modified
Read the three lengths: the original is untouched, and so is the second name for it.
Option Strict On
Imports System
Imports System.Collections.Generic
Module MutationDemo
Sub AddItem(items As List(Of Integer))
items.Add(99)
End Sub
Sub Main()
Dim numbers As New List(Of Integer) From {1, 2, 3}
Dim alias_ As List(Of Integer) = numbers
AddItem(numbers)
Console.WriteLine(numbers.Count)
Console.WriteLine(alias_.Count)
End Sub
End Moduledefmodule Adder do
def add_item(items) do
items ++ [99]
end
end
numbers = [1, 2, 3]
also = numbers
longer = Adder.add_item(numbers)
IO.inspect(length(numbers))
IO.inspect(length(also))
IO.inspect(length(longer))Every value in Elixir is immutable.
add_item cannot change the list it was given — it builds and returns a new one — so no function can surprise its caller by mutating an argument, and no second reference can be changed out from under you. That removes a whole category of bug that Visual Basic makes easy, and it is what allows the processes section later to run things concurrently with no locks at all: there is no shared mutable state to protect.A name can be rebound; the value cannot
This looks like assignment and mostly behaves like it — the distinction matters once a function has captured the name.
Option Strict On
Imports System
Module RebindDemo
Sub Main()
Dim total As Integer = 1
total = total + 1
total = total + 1
Console.WriteLine(total)
End Sub
End Moduletotal = 1
total = total + 1
total = total + 1
IO.puts(total)
text = "abc"
upper = String.upcase(text)
IO.puts(text)
IO.puts(upper)A name may be rebound to a new value, so
total = total + 1 works and reads normally. What cannot happen is the value changing: String.upcase(text) returns a new string and leaves text alone. The difference from Visual Basic shows when something has already captured the old value — a spawned process, an anonymous function — which keeps seeing what it captured rather than what the name now points to.The pipe operator
What a LINQ chain reads like when the functions do not belong to the object.
Option Strict On
Imports System
Imports System.Linq
Module PipeDemo
Sub Main()
Dim numbers() As Integer = {5, 3, 9, 1, 7, 2}
Dim total = numbers.
Where(Function(number) number > 2).
Select(Function(number) number * 10).
Sum()
Console.WriteLine(total)
End Sub
End Moduletotal =
[5, 3, 9, 1, 7, 2]
|> Enum.filter(fn number -> number > 2 end)
|> Enum.map(fn number -> number * 10 end)
|> Enum.sum()
IO.puts(total)
IO.puts(" hello " |> String.trim() |> String.upcase())x |> f(a) means f(x, a) — it feeds the value on the left in as the first argument. That is why every function in Enum and String takes its data first, and why a pipeline reads top to bottom in the order the work happens, exactly like a method chain but built from plain functions. It is the single most characteristic thing in Elixir code, and the reason immutability does not become tedious: each step returns a new value and hands it on.Pattern Matching
= is a match, not an assignment
The most important sentence on this page:
= asserts that two shapes agree, and binds names where they differ.Option Strict On
Imports System
Module MatchDemo
Sub Main()
Dim result = (True, "loaded")
' Read the parts out one at a time
Dim succeeded As Boolean = result.Item1
Dim message As String = result.Item2
Console.WriteLine($"{succeeded} {message}")
End Sub
End Module{status, message} = {:ok, "loaded"}
IO.puts(status)
IO.puts(message)
[first, second | rest] = [1, 2, 3, 4]
IO.inspect({first, second, rest})
%{name: who} = %{name: "Ada", age: 36}
IO.puts(who)
1 = 1
IO.puts("a match that binds nothing still succeeds")= is the match operator. {status, message} = {:ok, "loaded"} checks that the right side is a two-element tuple and binds the names to its parts. It is not assignment: 1 = 1 is a legal expression, and 2 = 1 raises a MatchError. This is how tuples, lists and maps are taken apart everywhere in Elixir, and it is why the language needs no Item1/Item2 and no destructuring syntax bolted on — matching is the syntax.Multiple function heads
The
Select Case ladder moves into the function signature, one clause per case.Option Strict On
Imports System
Module ClauseDemo
Function Describe(code As Integer) As String
Select Case code
Case 0
Return "none"
Case 1
Return "one"
Case Is < 0
Return "negative"
Case Else
Return "many"
End Select
End Function
Sub Main()
Console.WriteLine(Describe(0))
Console.WriteLine(Describe(1))
Console.WriteLine(Describe(-4))
Console.WriteLine(Describe(9))
End Sub
End Moduledefmodule Describer do
def describe(0), do: "none"
def describe(1), do: "one"
def describe(count) when count < 0, do: "negative"
def describe(_count), do: "many"
end
IO.puts(Describer.describe(0))
IO.puts(Describer.describe(1))
IO.puts(Describer.describe(-4))
IO.puts(Describer.describe(9))A function may be defined several times with different patterns, and Elixir tries them top to bottom.
Case Is < 0 becomes a guard, when count < 0. _count matches anything and the leading underscore says the value is unused. do: on one line is the short form of do ... end. This replaces both overloading (which Elixir has, by arity) and most if ladders, and it is the shape most Elixir code takes.case, cond and with
Three constructs where Visual Basic has one
If ladder, and each is for a different job.Option Strict On
Imports System
Module CaseDemo
Function Load(id As Integer) As String
If id <= 0 Then Return "bad id"
Dim value As Integer
If Not Integer.TryParse(id.ToString(), value) Then Return "unparsable"
If value > 100 Then Return "too large"
Return $"record {value}"
End Function
Sub Main()
Console.WriteLine(Load(7))
Console.WriteLine(Load(-1))
Console.WriteLine(Load(500))
End Sub
End Moduledefmodule Loader do
def load(id) do
with true <- id > 0,
true <- id <= 100 do
"record #{id}"
else
_ -> "rejected #{id}"
end
end
def classify(value) do
cond do
value < 0 -> "negative"
value == 0 -> "zero"
true -> "positive"
end
end
end
IO.puts(Loader.load(7))
IO.puts(Loader.load(-1))
IO.puts(Loader.load(500))
IO.puts(Loader.classify(-3))
case {:ok, 42} do
{:ok, value} -> IO.puts("got #{value}")
{:error, reason} -> IO.puts("failed #{reason}")
endcase matches one value against patterns — Select Case that can destructure. cond takes boolean branches and is the closest thing to an If/ElseIf chain, with true -> as the Else. with is the one worth learning: it chains steps that must each match, and jumps to else the moment one does not — which is exactly the "check, check, check, then do the work" shape the anchor column writes as a run of early returns.Lists, Tuples & Maps
Lists are linked, so they grow at the front
Nothing was added to anything — and the cheap operation is the one at the front.
Option Strict On
Imports System
Imports System.Collections.Generic
Module ListDemo
Sub Main()
Dim fruits As New List(Of String) From {"apple", "banana"}
fruits.Add("cherry")
Console.WriteLine(fruits.Count)
Console.WriteLine(fruits(0))
Console.WriteLine(String.Join(", ", fruits))
End Sub
End Modulefruits = ["apple", "banana"]
longer = ["cherry" | fruits]
appended = fruits ++ ["date"]
IO.puts(length(fruits))
IO.puts(hd(fruits))
IO.puts(Enum.join(longer, ", "))
IO.inspect(appended)
IO.inspect(Enum.at(fruits, 1))An Elixir list is an immutable singly-linked list.
[head | tail] prepends in constant time and shares the rest of the list, which is why Elixir code builds lists front-to-back and reverses at the end. ++ appends by copying the left side, so it is O(n) and wrong inside a loop. There is no Add, no index assignment, and Enum.at walks from the front — a list is not an array, and reaching for a random index is a sign the data wants to be a map or a tuple.Maps
The dictionary, immutable —
Map.put hands back a new map rather than changing the old one.Option Strict On
Imports System
Imports System.Collections.Generic
Module MapDemo
Sub Main()
Dim ages As New Dictionary(Of String, Integer) From {
{"Ada", 36},
{"Grace", 45}
}
ages("Alan") = 41
Console.WriteLine(ages.Count)
Console.WriteLine(ages("Ada"))
Dim found As Integer
ages.TryGetValue("Nobody", found)
Console.WriteLine(found)
End Sub
End Moduleages = %{"Ada" => 36, "Grace" => 45}
with_alan = Map.put(ages, "Alan", 41)
IO.puts(map_size(with_alan))
IO.puts(Map.fetch!(ages, "Ada"))
IO.inspect(Map.get(ages, "Nobody"))
IO.puts(Map.get(ages, "Nobody", 0))
settings = %{host: "localhost", port: 4000}
IO.puts(settings.port)
IO.inspect(%{settings | port: 8080})Dictionary(Of K, V) becomes a map, written %{key => value}. When the keys are atoms there is a shorthand, %{host: "localhost"}, and those maps allow dot access — settings.port. TryGetValue splits into Map.get(map, key, default) and Map.fetch(map, key), which returns {:ok, value} or :error. %{map | key => value} updates an existing key and raises if it is not there, which catches typos that Map.put would silently accept.Tuples, and the {:ok, value} convention
The
TryParse shape — a boolean plus a ByRef — becomes a tuple, and this convention is everywhere.Option Strict On
Imports System
Module TupleDemo
Function TryDivide(numerator As Integer, denominator As Integer, ByRef result As Integer) As Boolean
If denominator = 0 Then Return False
result = numerator \ denominator
Return True
End Function
Sub Main()
Dim answer As Integer
If TryDivide(10, 2, answer) Then
Console.WriteLine(answer)
Else
Console.WriteLine("cannot divide")
End If
End Sub
End Moduledefmodule Divider do
def divide(_numerator, 0), do: {:error, :division_by_zero}
def divide(numerator, denominator), do: {:ok, div(numerator, denominator)}
end
case Divider.divide(10, 2) do
{:ok, value} -> IO.puts(value)
{:error, reason} -> IO.puts("cannot divide: #{reason}")
end
case Divider.divide(10, 0) do
{:ok, value} -> IO.puts(value)
{:error, reason} -> IO.puts("cannot divide: #{reason}")
endA tuple is a fixed-size group written in braces, held contiguously, and matched on constantly. The convention
{:ok, value} / {:error, reason} is what the whole ecosystem returns from anything that can fail, so there is no output parameter and the failure carries a reason. Note the zero-denominator case is a separate function head rather than an if — that is idiomatic, and it is why a guard clause rarely appears in the body.Functions & Enum
LINQ becomes Enum
Every LINQ operator has a counterpart in one module, with the collection always first so it pipes.
Option Strict On
Imports System
Imports System.Linq
Module EnumDemo
Sub Main()
Dim numbers() As Integer = {5, 3, 9, 1, 7, 2}
Console.WriteLine(String.Join(", ", numbers.Where(Function(n) n > 2)))
Console.WriteLine(numbers.Sum())
Console.WriteLine(numbers.Any(Function(n) n > 8))
Console.WriteLine(numbers.OrderBy(Function(n) n).First())
Console.WriteLine(numbers.Aggregate(Function(a, b) a + b))
End Sub
End Modulenumbers = [5, 3, 9, 1, 7, 2]
IO.inspect(Enum.filter(numbers, fn n -> n > 2 end))
IO.puts(Enum.sum(numbers))
IO.puts(Enum.any?(numbers, fn n -> n > 8 end))
IO.puts(Enum.min(numbers))
IO.puts(Enum.reduce(numbers, 0, fn value, total -> total + value end))
IO.inspect(Enum.group_by(numbers, fn n -> rem(n, 2) == 0 end))
IO.inspect(Enum.chunk_every(numbers, 2))Where→Enum.filter, Select→Enum.map, Any→Enum.any?, All→Enum.all?, FirstOrDefault→Enum.find, OrderBy→Enum.sort_by, Aggregate→Enum.reduce, GroupBy→Enum.group_by. The trailing ? is part of the name and means the function returns a boolean, by convention throughout. Enum is eager; its lazy twin is Stream, which builds the pipeline and runs it only when something forces it — that is the one closest to LINQ.Anonymous functions
Note the dot before the parentheses — calling an anonymous function is deliberately different from calling a named one.
Option Strict On
Imports System
Imports System.Collections.Generic
Module LambdaDemo
Sub Main()
Dim twice As Func(Of Integer, Integer) = Function(value) value * 2
Dim operations As New Dictionary(Of String, Func(Of Integer, Integer)) From {
{"twice", twice}
}
Console.WriteLine(twice(21))
Console.WriteLine(operations("twice")(5))
End Sub
End Moduletwice = fn value -> value * 2 end
triple = &(&1 * 3)
operations = %{twice: twice, triple: triple}
IO.puts(twice.(21))
IO.puts(triple.(21))
IO.puts(operations.twice.(5))
IO.inspect(Enum.map([1, 2, 3], &String.duplicate("x", &1)))Function(value) value * 2 becomes fn value -> value * 2 end, and &(&1 * 3) is the terse capture form where &1 is the first argument. Calling one requires a dot: twice.(21), not twice(21) — the distinction exists because a bare twice(21) would mean a named function in the current module. &String.duplicate/2-style capture is Elixir's AddressOf, and there is no Func/Action type to declare.Recursion replaces the loop
There is no
For and no While — with nothing mutable, a loop counter has nowhere to live.Option Strict On
Imports System
Module LoopDemo
Sub Main()
Dim total As Integer = 0
For index As Integer = 1 To 10
total += index
Next
Console.WriteLine(total)
Dim countdown As Integer = 3
While countdown > 0
Console.Write(countdown & " ")
countdown -= 1
End While
Console.WriteLine()
End Sub
End Moduledefmodule Counter do
def sum_to(limit), do: sum_to(limit, 0)
defp sum_to(0, total), do: total
defp sum_to(remaining, total), do: sum_to(remaining - 1, total + remaining)
def countdown(0), do: :done
def countdown(n) do
IO.write("#{n} ")
countdown(n - 1)
end
end
IO.puts(Counter.sum_to(10))
IO.puts(Enum.sum(1..10))
Counter.countdown(3)
IO.puts("")Elixir has no loop constructs at all. Iteration is either recursion, with the state carried as arguments and a base-case clause to stop, or — far more often — a function from
Enum. Tail calls are optimized, so sum_to runs in constant stack space and a recursive loop over a million items is fine. In practice you will write Enum.sum(1..10) and reach for explicit recursion only when no Enum function fits.Structs Instead of Classes
Classes become structs and functions
The data and the behaviour separate: a struct holds fields, and functions take it as their first argument.
Option Strict On
Imports System
Public Class Person
Public Property Name As String = ""
Public Property Age As Integer
Public Sub New(name As String, age As Integer)
Me.Name = name
Me.Age = age
End Sub
Public Function Describe() As String
Return $"{Name}, age {Age}"
End Function
Public Function HadBirthday() As Person
Return New Person(Name, Age + 1)
End Function
End Class
Module StructDemo
Sub Main()
Dim person As New Person("Ada", 36)
Console.WriteLine(person.Describe())
Console.WriteLine(person.HadBirthday().Describe())
End Sub
End Moduledefmodule Person do
defstruct name: "", age: 0
def new(name, age), do: %Person{name: name, age: age}
def describe(%Person{name: name, age: age}) do
"#{name}, age #{age}"
end
def had_birthday(%Person{age: age} = person) do
%Person{person | age: age + 1}
end
end
person = Person.new("Ada", 36)
IO.puts(Person.describe(person))
IO.puts(person |> Person.had_birthday() |> Person.describe())There are no classes and no methods.
defstruct declares a map with known keys and a default for each; functions that work on it live in the same module and take it first, which is what makes person |> Person.describe() read like person.Describe(). Note the pattern in the function head — %Person{name: name} destructures the argument on the way in — and that had_birthday returns a new person, because nothing can be modified.Interfaces become behaviours
The module itself is the value being passed around — there is no instance to hold.
Option Strict On
Imports System
Imports System.Collections.Generic
Public Interface IGreeter
Function Greet(name As String) As String
End Interface
Public Class Formal
Implements IGreeter
Public Function Greet(name As String) As String Implements IGreeter.Greet
Return $"Good day, {name}."
End Function
End Class
Public Class Casual
Implements IGreeter
Public Function Greet(name As String) As String Implements IGreeter.Greet
Return $"Hi {name}!"
End Function
End Class
Module BehaviourDemo
Sub Main()
Dim greeters As New List(Of IGreeter) From {New Formal(), New Casual()}
For Each greeter As IGreeter In greeters
Console.WriteLine(greeter.Greet("Ada"))
Next
End Sub
End Moduledefmodule Greeter do
@callback greet(String.t()) :: String.t()
end
defmodule Formal do
@behaviour Greeter
@impl true
def greet(name), do: "Good day, #{name}."
end
defmodule Casual do
@behaviour Greeter
@impl true
def greet(name), do: "Hi #{name}!"
end
for module <- [Formal, Casual] do
IO.puts(module.greet("Ada"))
endA behaviour is a list of
@callback declarations; a module claims it with @behaviour, and the compiler warns about a missing or misspelled callback when @impl true is present. Since module names are atoms, a module can be stored in a variable and called dynamically — module.greet(...) — which is how polymorphism works without objects. This is what GenServer and Supervisor are: behaviours whose callbacks you fill in.Processes
Processes are the unit of concurrency
A process is not a thread — it is far cheaper, shares nothing, and talks only by message.
Option Strict On
Imports System
Imports System.Threading
Module SpawnDemo
Sub Main()
Dim result As String = ""
Dim gate As New ManualResetEventSlim(False)
Dim worker As New Thread(Sub()
result = "done on another thread"
gate.Set()
End Sub)
worker.Start()
gate.Wait()
Console.WriteLine(result)
End Sub
End Moduleparent = self()
spawn(fn ->
send(parent, {:result, "done in another process"})
end)
receive do
{:result, message} -> IO.puts(message)
after
1000 -> IO.puts("timed out")
end
IO.puts(is_pid(parent))spawn starts a lightweight process: a few hundred bytes, scheduled by the VM, with its own heap and no shared memory at all. Millions can run at once. Because nothing is shared there is nothing to lock — Thread, Mutex and ManualResetEvent have no counterpart and no purpose. Processes communicate with send and receive, and self() is the current process's identifier. The after clause is the timeout, which is always worth writing.GenServer replaces a stateful object
Mutable state does exist — it lives inside a process, and the lock disappears with it.
Option Strict On
Imports System
Imports System.Threading
Public Class Counter
Private _total As Integer = 0
Private ReadOnly _gate As New Object()
Public Sub Increment()
SyncLock _gate
_total += 1
End SyncLock
End Sub
Public ReadOnly Property Total As Integer
Get
SyncLock _gate
Return _total
End SyncLock
End Get
End Property
End Class
Module GenServerDemo
Sub Main()
Dim counter As New Counter()
counter.Increment()
counter.Increment()
Console.WriteLine(counter.Total)
End Sub
End Moduledefmodule Counter do
use GenServer
def start_link(initial), do: GenServer.start_link(__MODULE__, initial)
def increment(pid), do: GenServer.cast(pid, :increment)
def total(pid), do: GenServer.call(pid, :total)
@impl true
def init(initial), do: {:ok, initial}
@impl true
def handle_cast(:increment, total), do: {:noreply, total + 1}
@impl true
def handle_call(:total, _from, total), do: {:reply, total, total}
end
{:ok, pid} = Counter.start_link(0)
Counter.increment(pid)
Counter.increment(pid)
IO.puts(Counter.total(pid))A GenServer is a process that holds state and answers messages, which is what a stateful object is. State changes by returning the new state from a callback rather than by assignment. Because only that one process ever touches the state, no lock is needed and no race is possible — the
SyncLock in the anchor column has nothing to translate to. call waits for a reply, cast does not, and the split between the public functions and the callbacks is the standard shape.Let it crash
A process that fails simply dies, and something else is told about it.
Option Strict On
Imports System
Module CrashDemo
Sub Risky(shouldFail As Boolean)
If shouldFail Then Throw New InvalidOperationException("failed")
Console.WriteLine("worked")
End Sub
Sub Main()
' Every call site must decide what to do about failure
Try
Risky(False)
Risky(True)
Catch error_ As InvalidOperationException
Console.WriteLine($"caught: {error_.Message}")
End Try
End Sub
End Moduleparent = self()
child =
spawn(fn ->
receive do
:crash -> exit(:deliberate)
:ping -> send(parent, :pong)
end
end)
reference = Process.monitor(child)
send(child, :crash)
receive do
{:DOWN, ^reference, :process, _pid, reason} ->
IO.puts("child died: #{inspect(reason)}")
after
1000 -> IO.puts("no notification")
end
IO.puts("the parent is still running")The BEAM philosophy is let it crash: rather than defending every call site with a
Try, let a process fail and have a supervisor restart it in a known-good state. Process.monitor arranges the {:DOWN, ...} message shown here; a real application uses Supervisor with a restart strategy instead. The ^ before reference is the pin operator, meaning "match against this existing value" rather than rebinding the name — a small piece of syntax with no Visual Basic counterpart.Errors & Let It Crash
Expected failure is a return value
The
TryParse idea, generalized: anything that can fail says so in what it returns.Option Strict On
Imports System
Module ExpectedDemo
Sub Main()
Dim value As Integer
If Integer.TryParse("123", value) Then
Console.WriteLine(value)
End If
If Not Integer.TryParse("12x", value) Then
Console.WriteLine("not a number")
End If
End Sub
End Moduledefmodule Parser do
def parse(text) do
case Integer.parse(text) do
{number, ""} -> {:ok, number}
_ -> {:error, :not_a_number}
end
end
end
case Parser.parse("123") do
{:ok, number} -> IO.puts(number)
{:error, reason} -> IO.puts(reason)
end
case Parser.parse("12x") do
{:ok, number} -> IO.puts(number)
{:error, reason} -> IO.puts("not a number: #{reason}")
endA function that can fail returns
{:ok, value} or {:error, reason}, and the caller matches on it — no output parameter, and the failure carries a reason rather than a bare False. Note that Integer.parse("12x") returns {12, "x"}, the number and the leftover text, so matching {number, ""} is what insists the whole string was consumed. Many functions come in pairs: Map.fetch returns a tuple, Map.fetch! raises.Try/Catch becomes try/rescue
Exceptions exist and are reserved for the genuinely unexpected — the previous row is for everything else.
Option Strict On
Imports System
Module RescueDemo
Sub Main()
Try
Throw New ArgumentException("bad input")
Catch error_ As ArgumentException
Console.WriteLine($"caught: {error_.Message}")
Finally
Console.WriteLine("always runs")
End Try
End Sub
End Moduletry do
raise ArgumentError, "bad input"
rescue
error in ArgumentError -> IO.puts("caught: #{error.message}")
after
IO.puts("always runs")
end
result =
try do
div(10, 0)
rescue
ArithmeticError -> :undefined
end
IO.inspect(result)Try→try, Catch e As T→rescue e in T, Finally→after, Throw→raise. The whole try is an expression, so it can sit on the right of a =. The cultural difference matters more than the syntax: an Elixir codebase uses {:ok, _}/{:error, _} for anything it expects and reserves raise for bugs and unrecoverable states — which is also why a try block is a much rarer sight here than in .NET.Mix, Hex & Releases
NuGet becomes Hex, and mix does the rest
The tooling maps piece for piece, and one of these lines has no .NET counterpart at all.
Option Strict On
Imports System
Imports System.Collections.Generic
Module MixDemo
Sub Main()
Dim story As New Dictionary(Of String, String) From {
{"manifest", ".vbproj"},
{"registry", "NuGet"},
{"restore", "dotnet restore"},
{"test", "a separate test package"}
}
For Each entry In story
Console.WriteLine($"{entry.Key}: {entry.Value}")
Next
End Sub
End Modulestory = [
{"manifest", "mix.exs"},
{"registry", "Hex"},
{"fetch", "mix deps.get"},
{"test", "mix test — ExUnit ships with the language"},
{"format", "mix format — one style, not configurable"},
{"shell", "iex -S mix — a REPL attached to your app"}
]
for {key, value} <- story do
IO.puts("#{key}: #{value}")
endmix.exs is the .vbproj, Hex is nuget.org, mix deps.get is dotnet restore, and mix.lock pins versions. mix test, mix format and mix docs all ship with the language. The one with no equivalent is iex -S mix: an interactive shell attached to your running application, where you can call any function, inspect any process and — on a production node — hot-load new code. That is a debugging capability .NET simply does not have.What you are actually adopting
The ecosystem is small but unusually complete for the kind of work it is aimed at.
Option Strict On
Imports System
Imports System.Collections.Generic
Module OtpDemo
Sub Main()
Dim story As New Dictionary(Of String, String) From {
{"web", "ASP.NET Core"},
{"realtime", "SignalR"},
{"data", "Entity Framework"},
{"background", "Hangfire or a hosted service"}
}
For Each entry In story
Console.WriteLine($"{entry.Key}: {entry.Value}")
Next
End Sub
End Modulestory = [
{"web", "Phoenix"},
{"realtime", "Phoenix Channels and LiveView — built in"},
{"data", "Ecto"},
{"background", "a supervised process, no library needed"},
{"clustering", "built into the runtime"}
]
for {key, value} <- story do
IO.puts("#{key}: #{value}")
endPhoenix is the web framework and Ecto the data layer, and both are essentially the only choice — which is a simplification rather than a limitation. What is genuinely different is what needs no library: background jobs are supervised processes, real-time updates are built into the runtime, and connecting several nodes into a cluster is a runtime feature rather than an infrastructure project. LiveView is worth naming for this reader specifically: it renders a server-driven interactive UI with no JavaScript, which is closer to the WinForms mental model than anything in the JavaScript world.
⚠ Gotchas for Visual Basic Programmers
⚠ = can raise
The line that looks most like assignment is the one most likely to blow up.
Option Strict On
Imports System
Module MatchGotcha
Sub Main()
Dim value As Integer = 1
value = 2
Console.WriteLine(value)
End Sub
End Modulevalue = 1
value = 2
IO.puts(value)
result =
try do
{:ok, _} = {:error, :nope}
"matched"
rescue
MatchError -> "MatchError — the shapes did not agree"
end
IO.puts(result)Rebinding a bare name always succeeds, so
value = 2 behaves exactly as expected. But = is a match, so the moment the left side is a pattern rather than a plain name it can fail: {:ok, _} = {:error, :nope} raises MatchError. That is often what you want — an assertion that a call succeeded — and it is startling the first time a line that reads like an assignment ends the process.⚠ / always produces a float
Another page where the division habit transfers safely — with one detail in the last line.
Option Strict On
Imports System
Module DivisionGotcha
Sub Main()
Dim quotient As Integer = 17 \ 5
Dim exact As Double = 17 / 5
Dim remainder As Integer = 17 Mod 5
Console.WriteLine($"{quotient} {exact} {remainder}")
End Sub
End Modulequotient = div(17, 5)
exact = 17 / 5
remainder = rem(17, 5)
IO.puts("#{quotient} #{exact} #{remainder}")
IO.inspect(4 / 2)As in Visual Basic,
/ always produces a float and integer division has its own name: \ becomes div(a, b) and Mod becomes rem(a, b). No truncation trap. The detail worth noticing is that 4 / 2 is 2.0, not 2 — the result is a float even when it divides evenly, which matters when the value is about to be matched against an integer pattern or used as a list index.⚠ There is no loop, and no accumulator
The build-a-list-in-a-loop habit is the one that breaks first, and it breaks silently.
Option Strict On
Imports System
Imports System.Collections.Generic
Module AccumulatorGotcha
Sub Main()
Dim results As New List(Of Integer)
For index As Integer = 1 To 5
results.Add(index * index)
Next
Console.WriteLine(String.Join(", ", results))
End Sub
End Moduleresults =
for index <- 1..5 do
index * index
end
IO.inspect(results)
# This does NOT work — the outer name is unchanged:
collected = []
for index <- 1..3, do: [index | collected]
IO.inspect(collected)
IO.inspect(Enum.reduce(1..3, [], fn index, acc -> [index | acc] end))The
for in Elixir is a comprehension, not a loop: it collects what each iteration returns and hands back a list. It does not repeat a block for its side effects. So the very common Visual Basic shape — declare an empty list, add to it in a loop — cannot work, because nothing can be added to a list and the outer name is never reassigned. collected is still [] after the second block, with no error. Use the comprehension's result, or Enum.reduce when the accumulation is more complicated.⚠ Two kinds of text
Single and double quotes are not interchangeable here — they produce different types.
Option Strict On
Imports System
Module TextGotcha
Sub Main()
Dim text As String = "hello"
Dim letters() As Char = text.ToCharArray()
Console.WriteLine(text)
Console.WriteLine(letters.Length)
Console.WriteLine(text.Length)
End Sub
End Moduletext = "hello"
letters = ~c"hello"
IO.inspect(text)
IO.inspect(letters)
IO.inspect(is_binary(text))
IO.inspect(is_list(letters))
IO.puts(String.length(text))
IO.puts(byte_size("café"))
IO.puts(String.length("café"))A double-quoted string is a binary (UTF-8 bytes) and is what you want. A
~c sigil — historically single quotes — produces a charlist, a list of code points, which exists for Erlang interoperability and turns up unexpectedly when calling Erlang libraries. IO.inspect showing ~c"..." where you expected text is the usual symptom. Note also that byte_size and String.length differ for anything non-ASCII: "café" is 5 bytes and 4 characters.⚠ No My namespace, and no desktop at all
Everything has a counterpart — but the shape of the application changes more here than anywhere else on this anchor.
Option Strict On
Imports System
Module PlatformGotcha
Sub Main()
Console.WriteLine(Environment.MachineName.Length > 0)
Console.WriteLine(IsNumeric("42"))
Console.WriteLine(Now.Year > 2000)
End Sub
End ModuleIO.puts(is_atom(node()))
IO.puts(
case Integer.parse("42") do
{_number, ""} -> true
_ -> false
end
)
IO.puts(:erlang.system_time(:second) > 1_000_000_000)IsNumeric becomes an Integer.parse match, My.Computer.FileSystem becomes File and Path, and node() names the current BEAM node. Now becomes DateTime.utc_now() or Date.utc_today() in ordinary Elixir — the example reaches for the lower-level :erlang.system_time/1 instead, because the calendar functions are among the things the small in-browser VM does not carry. The honest summary: Elixir has no desktop GUI story. A WinForms application does not port. What it is exceptionally good at is long-running server work — web applications, real-time systems, message handling — so the decision to weigh is whether the thing you maintain is that kind of thing, not whether the syntax appeals.