Back to skills
extension
Category: Development & EngineeringNo API key required

convert-scala-erlang

Convert Scala code to idiomatic Erlang. Use when migrating Scala projects to Erlang/OTP, translating JVM functional patterns to BEAM/OTP patterns, or refactoring Scala codebases to leverage Erlang's fault-tolerance and distribution. Extends meta-convert-dev with Scala-to-Erlang specific patterns.

personAuthor: jakexiaohubgithub

Convert Scala to Erlang

Convert Scala code to idiomatic Erlang. This skill extends meta-convert-dev with Scala-to-Erlang specific type mappings, idiom translations, and tooling for migrating functional JVM code to the BEAM VM and OTP framework.

This Skill Extends

  • meta-convert-dev - Foundational conversion patterns (APTV workflow, testing strategies)

For general concepts like the Analyze → Plan → Transform → Validate workflow, testing strategies, and common pitfalls, see the meta-skill first.

This Skill Adds

  • Type mappings: Scala types → Erlang types and records
  • Idiom translations: Scala patterns → idiomatic Erlang/OTP
  • Error handling: Scala Either/Try/Option → Erlang tuples and let-it-crash
  • Concurrency patterns: Scala Future/Akka → Erlang processes and OTP behaviors
  • Platform migration: JVM → BEAM VM and OTP
  • Memory model shift: JVM shared heap → BEAM per-process heaps

This Skill Does NOT Cover

  • General conversion methodology - see meta-convert-dev
  • Scala language fundamentals - see lang-scala-dev
  • Erlang language fundamentals - see lang-erlang-dev
  • Reverse conversion (Erlang → Scala) - see convert-erlang-scala

Quick Reference

| Scala | Erlang | Notes | |-------|--------|-------| | String | binary() / list() | UTF-8 binary or char list | | Int | integer() | Arbitrary precision in Erlang | | Long | integer() | Same as Int in Erlang | | Double | float() | IEEE 754 double | | Boolean | true / false | Atoms | | Option[T] | {ok, Value} \| error | Tagged tuple | | Some(x) | {ok, X} | Success tuple | | None | error / undefined | Absence | | Either[L,R] | {ok, Value} \| {error, Reason} | Tagged tuple | | Right(x) | {ok, X} | Success | | Left(e) | {error, Reason} | Error | | Try[T] | {ok, Value} \| {error, Reason} | Exception handling | | List[T] | list() | Linked list | | Vector[T] | list() / array() | List or array module | | Array[T] | tuple() / array() | Fixed-size tuple or array | | Map[K,V] | #{K => V} / maps:map() | Map literal or maps module | | Set[T] | sets:set() / ordsets | Sets module | | case class | -record(name, {...}) | Record definition | | sealed trait | Tagged tuples | Discriminated union | | Future[T] | pid() / gen_server | Lightweight process | | object | -module(name). | Singleton as module |

When Converting Code

  1. Analyze source thoroughly before writing target
  2. Map types first - create type equivalence table
  3. Preserve semantics over syntax similarity
  4. Adopt Erlang/OTP idioms - don't write "Scala code in Erlang syntax"
  5. Embrace let-it-crash - replace defensive programming with supervision
  6. Handle edge cases - null safety, error paths, process lifecycle
  7. Test equivalence - same inputs → same outputs

Type System Mapping

Primitive Types

| Scala | Erlang | Notes | |-------|--------|-------| | String | binary() | UTF-8 binary (most common): <<"Hello">> | | String | string() | Character list (for compatibility): "Hello" | | Int | integer() | 32-bit in Scala, arbitrary precision in Erlang | | Long | integer() | 64-bit in Scala, arbitrary precision in Erlang | | Short | integer() | 16-bit in Scala, arbitrary precision in Erlang | | Byte | integer() | 8-bit in Scala, 0-255 in Erlang | | Double | float() | IEEE 754 double precision | | Float | float() | IEEE 754 single in Scala, double in Erlang | | Boolean | true / false | Atoms (lowercase) | | Char | integer() | Unicode codepoint | | Unit | ok | Atom representing success | | Any | any() / term() | Any Erlang term | | Nothing | N/A | Bottom type, no Erlang equivalent |

Option and Either Types

| Scala | Erlang | Notes | |-------|--------|-------| | None | undefined / error | Atom for absence | | Some(x) | {ok, X} | Tagged tuple for presence | | Option[T] | {ok, Value} \| error \| undefined | Common pattern | | Right(x) | {ok, X} | Success tuple | | Left(e) | {error, Reason} | Error tuple with reason | | Either[L,R] | {ok, Value} \| {error, Reason} | Standard error pattern | | Success(x) | {ok, X} | Try success | | Failure(e) | {error, Reason} | Try failure | | Try[T] | {ok, Value} \| {error, Reason} | Exception handling |

Collection Types

| Scala | Erlang | Notes | |-------|--------|-------| | List[T] | list() | Linked list: [1, 2, 3] | | Vector[T] | list() | No direct equivalent, use list | | Array[T] | tuple() | Fixed-size: {1, 2, 3} | | Array[T] | array:array() | Mutable array module | | Seq[T] | list() | Generic sequence → list | | LazyList[T] | Process-based stream | Stream via gen_server | | Map[K,V] | #{K => V} | Map literal (Erlang 17+) | | Map[K,V] | dict:dict() | Legacy dict module | | Set[T] | sets:set() | Unordered set | | Set[T] | ordsets:ordset() | Ordered set (list-based) | | (A, B) (tuple) | {A, B} | Tuple literal | | (A, B, C) | {A, B, C} | N-tuple | | Range | lists:seq(Start, End) | Sequence generation |

Case Classes and Sealed Traits

| Scala | Erlang | Notes | |-------|--------|-------| | case class Person(name: String, age: Int) | -record(person, {name :: binary(), age :: integer()}). | Record definition | | Person("Alice", 30) | #person{name = <<"Alice">>, age = 30} | Record creation | | person.name | Person#person.name | Field access | | person.copy(age = 31) | Person#person{age = 31} | Record update | | sealed trait Shape | Tagged tuples | Discriminated union | | case class Circle(r: Double) extends Shape | {circle, Radius} | Tagged tuple variant | | case class Rectangle(w: Double, h: Double) extends Shape | {rectangle, Width, Height} | Tagged tuple variant | | case object Empty extends Shape | empty | Atom for singleton |

Function Types

| Scala | Erlang | Notes | |-------|--------|-------| | A => B | fun((A) -> B) | Anonymous function | | (A, B) => C | fun((A, B) -> C) | Multi-param function | | A => B => C | fun((A) -> fun((B) -> C) end end) | Curried (uncommon in Erlang) | | Function1[A,B] | fun((A) -> B) | Function type | | () => A | fun(() -> A) | Nullary function | | A => Unit | fun((A) -> ok) | Side-effect function |

Generic Types

| Scala | Erlang | Notes | |-------|--------|-------| | [T] | term() | Any type (runtime polymorphism) | | List[T] | list(T) | Parameterized type spec | | Option[T] | {ok, T} \| error | Type spec pattern | | Either[L,R] | {ok, R} \| {error, L} | Type spec pattern | | Type bound T <: Upper | Guard clause | when is_record(X, name) | | Type bound T >: Lower | N/A | No lower bounds in Erlang |


Idiom Translation

Pattern 1: Option Handling

Scala:

def findUser(id: String): Option[User] =
  users.find(_.id == id)

val name = findUser("123")
  .map(_.name)
  .getOrElse("Unknown")

Erlang:

-spec find_user(binary()) -> {ok, user()} | error.
find_user(Id) ->
    case lists:search(fun(U) -> maps:get(id, U) =:= Id end, users()) of
        {value, User} -> {ok, User};
        false -> error
    end.

get_name(UserId) ->
    case find_user(UserId) of
        {ok, User} -> maps:get(name, User);
        error -> <<"Unknown">>
    end.

Why this translation:

  • Scala's Option.map becomes pattern matching in Erlang
  • getOrElse becomes the error clause in case expression
  • Erlang uses {ok, Value} | error tuples instead of Some/None
  • Type specs replace Scala type annotations

Pattern 2: Either-Based Error Handling

Scala:

sealed trait Error
case object DivisionByZero extends Error
case class InvalidInput(msg: String) extends Error

def divide(x: Double, y: Double): Either[Error, Double] =
  if (y == 0.0) Left(DivisionByZero)
  else Right(x / y)

val result = for {
  a <- divide(10.0, 2.0)
  b <- divide(20.0, 4.0)
  c <- divide(a, b)
} yield c

Erlang:

-type error_reason() :: division_by_zero | {invalid_input, binary()}.

-spec divide(float(), float()) -> {ok, float()} | {error, error_reason()}.
divide(_X, 0.0) ->
    {error, division_by_zero};
divide(X, Y) ->
    {ok, X / Y}.

-spec calculate() -> {ok, float()} | {error, error_reason()}.
calculate() ->
    case divide(10.0, 2.0) of
        {ok, A} ->
            case divide(20.0, 4.0) of
                {ok, B} ->
                    divide(A, B);
                {error, Reason} -> {error, Reason}
            end;
        {error, Reason} -> {error, Reason}
    end.

Why this translation:

  • Scala for-comprehensions become nested case statements
  • Either[L,R] maps to {ok, Value} | {error, Reason} tuples
  • Sealed traits become atoms or tagged tuples
  • Pattern matching on error tuples replaces monadic bind

Pattern 3: List Processing

Scala:

val result = items
  .filter(_.active)
  .map(_.value)
  .sum

Erlang:

calculate_result(Items) ->
    lists:foldl(
        fun(X, Acc) -> Acc + X end,
        0,
        [maps:get(value, X) || X <- Items, maps:get(active, X)]
    ).

% Alternative: using lists module functions
calculate_result_alt(Items) ->
    Active = lists:filter(fun(X) -> maps:get(active, X) end, Items),
    Values = lists:map(fun(X) -> maps:get(value, X) end, Active),
    lists:sum(Values).

Why this translation:

  • Scala method chaining becomes list comprehension or nested function calls
  • List comprehension is more idiomatic for filter+map in Erlang
  • lists:sum/1 directly replaces .sum
  • Both approaches are valid; comprehension is more concise

Pattern 4: Case Class Pattern Matching

Scala:

case class Person(firstName: String, lastName: String, age: Int)

def getFullName(person: Person): String = person match {
  case Person(first, last, _) => s"$first $last"
}

def isAdult(person: Person): Boolean = person match {
  case Person(_, _, age) if age >= 18 => true
  case _ => false
}

Erlang:

-record(person, {
    first_name :: binary(),
    last_name :: binary(),
    age :: integer()
}).

get_full_name(#person{first_name = First, last_name = Last}) ->
    <<First/binary, " ", Last/binary>>.

is_adult(#person{age = Age}) when Age >= 18 ->
    true;
is_adult(_) ->
    false.

Why this translation:

  • Scala case class patterns map to Erlang record patterns
  • Guards (when) work similarly in both languages
  • Scala string interpolation becomes binary concatenation
  • Function clauses with pattern matching replace match expressions

Pattern 5: Sealed Trait / ADT

Scala:

sealed trait Result[+T]
case class Success[T](value: T) extends Result[T]
case class Failure(error: String) extends Result[Nothing]
case object Pending extends Result[Nothing]

def process[T](result: Result[T]): String = result match {
  case Success(value) => s"Got: $value"
  case Failure(error) => s"Error: $error"
  case Pending => "Still waiting..."
}

Erlang:

-type result(T) :: {success, T} | {failure, binary()} | pending.

-spec process(result(term())) -> binary().
process({success, Value}) ->
    iolist_to_binary(io_lib:format("Got: ~p", [Value]));
process({failure, Error}) ->
    <<"Error: ", Error/binary>>;
process(pending) ->
    <<"Still waiting...">>.

Why this translation:

  • Sealed traits become tagged tuples or atoms
  • Case objects become atoms
  • Pattern matching translates directly
  • Type parameters in Scala become type variables in specs

Pattern 6: Singleton Object

Scala:

object MathUtils {
  val PI = 3.14159

  def square(x: Int): Int = x * x

  def cube(x: Int): Int = x * x * x
}

// Usage
val result = MathUtils.square(5)

Erlang:

-module(math_utils).
-export([square/1, cube/1]).

-define(PI, 3.14159).

square(X) -> X * X.

cube(X) -> X * X * X.

% Usage
Result = math_utils:square(5).

Why this translation:

  • Scala objects become Erlang modules
  • Public methods become exported functions
  • Constants become macros or module attributes
  • Fully qualified calls use module:function syntax

Paradigm Translation

Mental Model Shift: JVM → BEAM

| Scala Concept | Erlang Approach | Key Insight | |---------------|-----------------|-------------| | Shared mutable state | Isolated process state | Each process has private memory | | Class with methods | Record + module functions | Data and behavior separated | | Inheritance | Protocol implementation | Favor behaviors over hierarchies | | Thread pool | Lightweight processes | Millions of processes, not threads | | Synchronized blocks | Message passing | No shared memory, communicate via messages | | Exception handling | Let-it-crash + supervision | Failures are isolated and handled by supervisors | | Static typing | Dynamic with dialyzer | Runtime flexibility with static analysis |

Concurrency Mental Model

| Scala Pattern | Erlang Pattern | Conceptual Translation | |---------------|----------------|------------------------| | Future[T] | spawn/1 + message passing | Async computation → lightweight process | | Promise[T] | Process mailbox | Future completion → message receipt | | Await.result | receive ... end | Blocking wait → selective receive | | Akka Actor | gen_server behavior | Stateful actor → OTP behavior | | Akka receive | handle_call/handle_cast | Message handling → callback functions | | Akka Supervisor | OTP supervisor | Fault tolerance → supervision tree | | ExecutionContext | Scheduler | Thread pool → BEAM scheduler | | Thread-safe collections | Process dictionary / ETS | Shared state → process-local or ETS |


Error Handling

Scala Try/Either → Erlang Tuples and Let-it-Crash

Scala defensive style:

def parseAndProcess(input: String): Try[Int] = Try {
  val num = input.toInt
  if (num < 0) throw new IllegalArgumentException("Negative")
  num * 2
}

val result = parseAndProcess("42") match {
  case Success(value) => println(s"Result: $value")
  case Failure(ex) => println(s"Error: ${ex.getMessage}")
}

Erlang let-it-crash style:

-spec parse_and_process(binary()) -> integer().
parse_and_process(Input) ->
    Num = binary_to_integer(Input),
    true = Num >= 0,  % Crashes if false
    Num * 2.

% Caller can catch or let supervisor handle
-spec safe_parse_and_process(binary()) -> {ok, integer()} | {error, term()}.
safe_parse_and_process(Input) ->
    try
        {ok, parse_and_process(Input)}
    catch
        _:Reason -> {error, Reason}
    end.

Why this translation:

  • Erlang prefers crashing over defensive checks
  • Supervisors restart failed processes
  • Use {ok, Value} | {error, Reason} for expected errors
  • Let unexpected errors crash for debugging clarity

Error Propagation Patterns

| Scala Pattern | Erlang Pattern | Notes | |---------------|----------------|-------| | Try { ... } | try ... catch | Exception handling | | .recover { case ... } | catch clauses | Error recovery | | Either.flatMap | Nested case | Error propagation | | Throwing exceptions | throw/exit/error | Rare in idiomatic Erlang | | Custom exception classes | Atoms or tuples | Error reasons as atoms | | Stack trace capture | erlang:get_stacktrace() | For debugging |


Concurrency Patterns

Scala Future → Erlang Process

Scala:

import scala.concurrent.{Future, ExecutionContext}
import ExecutionContext.Implicits.global

def fetchData(id: String): Future[Data] = Future {
  // Simulate async operation
  Thread.sleep(1000)
  Data(id, "result")
}

val result = for {
  data1 <- fetchData("1")
  data2 <- fetchData("2")
} yield combine(data1, data2)

result.onComplete {
  case Success(combined) => println(combined)
  case Failure(ex) => println(s"Error: $ex")
}

Erlang:

-spec fetch_data(binary()) -> data().
fetch_data(Id) ->
    timer:sleep(1000),
    #{id => Id, result => <<"result">>}.

-spec async_fetch() -> pid().
async_fetch() ->
    Parent = self(),
    spawn(fun() ->
        Data1 = fetch_data(<<"1">>),
        Data2 = fetch_data(<<"2">>),
        Combined = combine(Data1, Data2),
        Parent ! {result, Combined}
    end).

% Usage
Pid = async_fetch(),
receive
    {result, Combined} -> io:format("~p~n", [Combined])
after 5000 ->
    io:format("Timeout~n")
end.

Why this translation:

  • Futures become spawned processes
  • onComplete becomes receive pattern matching
  • Sequential async (for-comprehension) becomes sequential process code
  • Timeout handling is explicit in Erlang

Akka Actor → gen_server

Scala (Akka):

import akka.actor.{Actor, ActorRef, Props}

case class Get(key: String)
case class Put(key: String, value: String)

class KeyValueStore extends Actor {
  private var store = Map.empty[String, String]

  def receive: Receive = {
    case Get(key) =>
      sender() ! store.get(key)

    case Put(key, value) =>
      store = store + (key -> value)
      sender() ! "OK"
  }
}

// Usage
val store = system.actorOf(Props[KeyValueStore])
store ! Put("name", "Alice")
store ! Get("name")

Erlang (gen_server):

-module(kv_store).
-behaviour(gen_server).

-export([start_link/0, get/1, put/2]).
-export([init/1, handle_call/3, handle_cast/2, terminate/2, code_change/3]).

-record(state, {store = #{} :: map()}).

%% API
start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

get(Key) ->
    gen_server:call(?MODULE, {get, Key}).

put(Key, Value) ->
    gen_server:call(?MODULE, {put, Key, Value}).

%% Callbacks
init([]) ->
    {ok, #state{}}.

handle_call({get, Key}, _From, State = #state{store = Store}) ->
    Result = maps:get(Key, Store, undefined),
    {reply, Result, State};

handle_call({put, Key, Value}, _From, State = #state{store = Store}) ->
    NewStore = Store#{Key => Value},
    {reply, ok, State#state{store = NewStore}}.

handle_cast(_Msg, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    ok.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

Why this translation:

  • Akka actors map to gen_server behaviors
  • receive block becomes handle_call/handle_cast callbacks
  • sender() is implicit in gen_server's From parameter
  • State management is explicit in callback return tuples
  • OTP provides more structure than raw Akka actors

Module and Package Structure

Scala Package → Erlang Module

Scala:

package com.example.myapp

object UserService {
  def createUser(name: String): User = ???
  def deleteUser(id: Int): Unit = ???
}

Erlang:

-module(user_service).
-export([create_user/1, delete_user/1]).

create_user(Name) ->
    % implementation
    ok.

delete_user(Id) ->
    % implementation
    ok.

Translation notes:

  • Scala packages become application structure (apps/src directories)
  • Scala objects become modules
  • Package imports become module calls
  • No nested modules in Erlang (flat namespace)

Common Pitfalls

  1. Overusing exceptions: Erlang prefers let-it-crash for unexpected errors and {ok, Value} | {error, Reason} for expected errors. Don't translate every Try to try...catch.

  2. Shared mutable state: Scala's var and mutable collections don't translate directly. Use process state or ETS tables instead.

  3. Type erasure issues: Scala's generic types are erased at runtime. Erlang is dynamically typed, but Dialyzer type specs help catch errors.

  4. Numeric precision: Scala's Int is 32-bit, but Erlang's integer() is arbitrary precision. Document overflow assumptions.

  5. String encoding: Scala strings are UTF-16; Erlang binaries are UTF-8. Use <<"binary">> syntax for UTF-8 strings.

  6. Null handling: Scala has null (avoid it), None, and Option. Erlang uses undefined, error, or {ok, Value}. Be consistent.

  7. Currying: Scala's curried functions are uncommon in Erlang. Flatten to multi-argument functions.

  8. Implicits: Scala's implicit parameters and conversions have no Erlang equivalent. Make dependencies explicit.

  9. Case object equality: Scala case objects use reference equality. Erlang atoms use value equality.

  10. Pattern match exhaustiveness: Scala's compiler checks exhaustiveness; Erlang/Dialyzer warns but doesn't enforce. Add catch-all clauses.


Limitations

Coverage Gaps

| Pillar | Scala Skill | Erlang Skill | Mitigation | |--------|-------------|--------------|------------| | Module | ~ | ✓ | Scala packages explained in context | | Error | ✓ | ✓ | Full coverage | | Concurrency | ✓ | ✓ | Full coverage | | Metaprogramming | ~ | ✓ | Scala macros mentioned where relevant | | Zero/Default | ~ | ✓ | Option/None covered in idioms | | Serialization | ~ | ✓ | Refer to patterns-serialization-dev | | Build | ✓ | ✓ | Full coverage | | Testing | ✓ | ✓ | Full coverage |

Combined Score: 13.5/16 (Good - proceed with pattern skill references)

Known Limitations

  1. Serialization: This skill has limited guidance on JSON/serialization libraries because lang-scala-dev lacks dedicated serialization coverage. Refer to patterns-serialization-dev for JSON handling patterns.

  2. Metaprogramming: Scala macros and compile-time metaprogramming have limited coverage. Erlang macros and parse transforms are covered in lang-erlang-dev.

  3. Module systems: Scala's package objects and imports are not fully covered. Module structure patterns are provided in context.

External Resources Used

| Resource | What It Provided | Reliability | |----------|------------------|-------------| | Scala official docs | Type system, collections API | High | | Erlang official docs | OTP behaviors, gen_server patterns | High | | lang-scala-dev skill | Scala fundamentals, concurrency | High | | lang-erlang-dev skill | Erlang patterns, OTP | High | | convert-erlang-scala | Reverse conversion insights | High |


Tooling

| Tool | Purpose | Notes | |------|---------|-------| | rebar3 | Erlang build tool | Successor to rebar, standard for modern Erlang | | Dialyzer | Static analysis | Catches type errors despite dynamic typing | | EUnit | Unit testing | Built-in test framework | | Common Test | Integration testing | OTP's comprehensive test framework | | Observer | Live system inspection | GUI for monitoring processes, ETS, applications | | Recon | Production debugging | Runtime inspection and debugging | | PropEr | Property-based testing | QuickCheck-like testing for Erlang | | Elvis | Style checker | Erlang linter and style enforcer | | Relx | Release building | Creates production releases | | Hex | Package manager | Erlang/Elixir package registry |

No direct transpilers exist from Scala to Erlang. Manual conversion is required.


Examples

Example 1: Simple - Type and Function Translation

Before (Scala):

case class Point(x: Double, y: Double)

def distance(p1: Point, p2: Point): Double = {
  val dx = p2.x - p1.x
  val dy = p2.y - p1.y
  Math.sqrt(dx * dx + dy * dy)
}

val origin = Point(0.0, 0.0)
val point = Point(3.0, 4.0)
val dist = distance(origin, point)  // 5.0

After (Erlang):

-module(geometry).
-export([distance/2]).

-record(point, {x :: float(), y :: float()}).

distance(P1, P2) ->
    Dx = P2#point.x - P1#point.x,
    Dy = P2#point.y - P1#point.y,
    math:sqrt(Dx * Dx + Dy * Dy).

% Usage
Origin = #point{x = 0.0, y = 0.0},
Point = #point{x = 3.0, y = 4.0},
Dist = distance(Origin, Point).  % 5.0

Example 2: Medium - Option and Error Handling

Before (Scala):

sealed trait ValidationError
case class InvalidEmail(email: String) extends ValidationError
case class UserNotFound(id: Int) extends ValidationError

case class User(id: Int, name: String, email: String)

def validateEmail(email: String): Either[ValidationError, String] =
  if (email.contains("@")) Right(email)
  else Left(InvalidEmail(email))

def findUser(id: Int): Option[User] =
  // Simulated database lookup
  if (id == 1) Some(User(1, "Alice", "alice@example.com"))
  else None

def getUserEmail(id: Int): Either[ValidationError, String] = {
  for {
    user <- findUser(id).toRight(UserNotFound(id))
    validEmail <- validateEmail(user.email)
  } yield validEmail
}

// Usage
getUserEmail(1) match {
  case Right(email) => println(s"Email: $email")
  case Left(InvalidEmail(e)) => println(s"Invalid email: $e")
  case Left(UserNotFound(id)) => println(s"User $id not found")
}

After (Erlang):

-module(user_validator).
-export([get_user_email/1]).

-record(user, {id :: integer(), name :: binary(), email :: binary()}).

-type validation_error() ::
    {invalid_email, binary()} |
    {user_not_found, integer()}.

-spec validate_email(binary()) -> {ok, binary()} | {error, validation_error()}.
validate_email(Email) ->
    case binary:match(Email, <<"@">>) of
        {_, _} -> {ok, Email};
        nomatch -> {error, {invalid_email, Email}}
    end.

-spec find_user(integer()) -> {ok, user()} | error.
find_user(1) ->
    {ok, #user{id = 1, name = <<"Alice">>, email = <<"alice@example.com">>}};
find_user(_) ->
    error.

-spec get_user_email(integer()) -> {ok, binary()} | {error, validation_error()}.
get_user_email(Id) ->
    case find_user(Id) of
        {ok, User} ->
            validate_email(User#user.email);
        error ->
            {error, {user_not_found, Id}}
    end.

% Usage
case get_user_email(1) of
    {ok, Email} ->
        io:format("Email: ~s~n", [Email]);
    {error, {invalid_email, E}} ->
        io:format("Invalid email: ~s~n", [E]);
    {error, {user_not_found, Id}} ->
        io:format("User ~p not found~n", [Id])
end.

Example 3: Complex - Actor/Process with State Management

Before (Scala with Akka):

import akka.actor.{Actor, ActorRef, Props}
import scala.collection.mutable

case class Subscribe(topic: String, subscriber: ActorRef)
case class Unsubscribe(topic: String, subscriber: ActorRef)
case class Publish(topic: String, message: String)
case class GetSubscribers(topic: String)

class PubSubBroker extends Actor {
  private val subscriptions = mutable.Map.empty[String, Set[ActorRef]]

  def receive: Receive = {
    case Subscribe(topic, subscriber) =>
      val current = subscriptions.getOrElse(topic, Set.empty)
      subscriptions(topic) = current + subscriber
      sender() ! "Subscribed"

    case Unsubscribe(topic, subscriber) =>
      subscriptions.get(topic).foreach { subs =>
        subscriptions(topic) = subs - subscriber
      }
      sender() ! "Unsubscribed"

    case Publish(topic, message) =>
      subscriptions.get(topic).foreach { subscribers =>
        subscribers.foreach(_ ! message)
      }
      sender() ! "Published"

    case GetSubscribers(topic) =>
      val subs = subscriptions.getOrElse(topic, Set.empty)
      sender() ! subs.size
  }
}

// Usage
val broker = system.actorOf(Props[PubSubBroker], "broker")
broker ! Subscribe("news", subscriberActor)
broker ! Publish("news", "Breaking news!")

After (Erlang with gen_server):

-module(pubsub_broker).
-behaviour(gen_server).

-export([start_link/0, subscribe/2, unsubscribe/2, publish/2, get_subscribers/1]).
-export([init/1, handle_call/3, handle_cast/2, terminate/2, code_change/3]).

-record(state, {
    subscriptions = #{} :: #{binary() => [pid()]}
}).

%% API
start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

subscribe(Topic, Subscriber) ->
    gen_server:call(?MODULE, {subscribe, Topic, Subscriber}).

unsubscribe(Topic, Subscriber) ->
    gen_server:call(?MODULE, {unsubscribe, Topic, Subscriber}).

publish(Topic, Message) ->
    gen_server:cast(?MODULE, {publish, Topic, Message}).

get_subscribers(Topic) ->
    gen_server:call(?MODULE, {get_subscribers, Topic}).

%% Callbacks
init([]) ->
    {ok, #state{}}.

handle_call({subscribe, Topic, Subscriber}, _From, State = #state{subscriptions = Subs}) ->
    Current = maps:get(Topic, Subs, []),
    NewSubs = case lists:member(Subscriber, Current) of
        true -> Subs;
        false -> Subs#{Topic => [Subscriber | Current]}
    end,
    {reply, subscribed, State#state{subscriptions = NewSubs}};

handle_call({unsubscribe, Topic, Subscriber}, _From, State = #state{subscriptions = Subs}) ->
    NewSubs = case maps:get(Topic, Subs, []) of
        [] -> Subs;
        List ->
            Subs#{Topic => lists:delete(Subscriber, List)}
    end,
    {reply, unsubscribed, State#state{subscriptions = NewSubs}};

handle_call({get_subscribers, Topic}, _From, State = #state{subscriptions = Subs}) ->
    Count = length(maps:get(Topic, Subs, [])),
    {reply, Count, State}.

handle_cast({publish, Topic, Message}, State = #state{subscriptions = Subs}) ->
    Subscribers = maps:get(Topic, Subs, []),
    lists:foreach(fun(Sub) -> Sub ! {message, Topic, Message} end, Subscribers),
    {noreply, State}.

terminate(_Reason, _State) ->
    ok.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

% Usage
{ok, _Pid} = pubsub_broker:start_link(),
pubsub_broker:subscribe(<<"news">>, self()),
pubsub_broker:publish(<<"news">>, <<"Breaking news!">>),
receive
    {message, <<"news">>, Msg} ->
        io:format("Received: ~s~n", [Msg])
after 5000 ->
    io:format("Timeout~n")
end.

Key differences in this example:

  • Akka's mutable Map becomes immutable Map in gen_server state
  • sender() is handled implicitly by gen_server's From parameter
  • Fire-and-forget messages (!) map to handle_cast
  • Request-reply messages map to handle_call
  • Supervision and lifecycle are managed by OTP

See Also

For more examples and patterns, see:

  • meta-convert-dev - Foundational patterns with cross-language examples
  • convert-erlang-scala - Reverse conversion (Erlang → Scala)
  • convert-fsharp-erlang - Similar functional JVM → BEAM conversion
  • lang-scala-dev - Scala development patterns
  • lang-erlang-dev - Erlang development patterns

Cross-cutting pattern skills (for areas not fully covered by lang-*-dev):

  • patterns-concurrency-dev - Async, channels, actors across languages
  • patterns-serialization-dev - JSON, validation across languages
  • patterns-metaprogramming-dev - Macros, compile-time code generation