PONYλM2Modula-2

Visual Basic.CodeCompared.To/Elixir

An interactive executable cheatsheet comparing Visual Basic and Elixir

Visual Basic (.NET 10) Elixir 1.17
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 Module
IO.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 Module
name = "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 Module
defmodule 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 Module
current = :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 Module
defmodule 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 Module
defmodule 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 Module
total = 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 Module
total = [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 Module
defmodule 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 Module
defmodule 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}") end
case 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 Module
fruits = ["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 Module
ages = %{"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 Module
defmodule 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}") end
A 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 Module
numbers = [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))
WhereEnum.filter, SelectEnum.map, AnyEnum.any?, AllEnum.all?, FirstOrDefaultEnum.find, OrderByEnum.sort_by, AggregateEnum.reduce, GroupByEnum.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 Module
twice = 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 Module
defmodule 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 Module
defmodule 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 Module
defmodule 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")) end
A 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 Module
parent = 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 Module
defmodule 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 Module
parent = 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 Module
defmodule 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}") end
A 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 Module
try 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)
Trytry, Catch e As Trescue e in T, Finallyafter, Throwraise. 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 Module
story = [ {"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}") end
mix.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 Module
story = [ {"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}") end
Phoenix 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 Module
value = 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 Module
quotient = 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 Module
results = 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 Module
text = "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 Module
IO.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.