Agent Skills: Elm ↔ Scala Conversion

Bidirectional conversion between Elm and Scala. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Elm↔Scala specific patterns. Use when migrating Elm frontend applications to Scala backends or full-stack Scala, translating The Elm Architecture to functional Scala patterns, or refactoring type-safe functional code from compile-time guarantees to more powerful type system features. Extends meta-convert-dev with Elm-to-Scala specific patterns.

UncategorizedID: arustydev/ai/convert-elm-scala

Repository

aRustyDevLicense: AGPL-3.0
72

Install this agent skill to your local

pnpm dlx add-skill https://github.com/aRustyDev/agents/tree/HEAD/content/skills/convert-elm-scala

Skill Files

Browse the full folder contents for convert-elm-scala.

Download Skill

Loading file tree…

content/skills/convert-elm-scala/SKILL.md

Skill Metadata

Name
convert-elm-scala
Description
Bidirectional conversion between Elm and Scala. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Elm↔Scala specific patterns. Use when migrating Elm frontend applications to Scala backends or full-stack Scala, translating The Elm Architecture to functional Scala patterns, or refactoring type-safe functional code from compile-time guarantees to more powerful type system features. Extends meta-convert-dev with Elm-to-Scala specific patterns.

Elm ↔ Scala Conversion

Bidirectional conversion between Elm and Scala. This skill extends meta-convert-dev with Elm↔Scala specific type mappings, idiom translations, and tooling for translating from frontend functional programming to backend/full-stack functional programming with more expressive types.

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: Elm's union types → Scala's sealed traits and case classes
  • Idiom translations: The Elm Architecture → functional Scala patterns (cats-effect, ZIO)
  • Error handling: Maybe/Result → Option/Either with rich combinators
  • Async patterns: Cmd/Sub → Future/IO/Task with effect systems
  • Type system: Simple types → advanced types (higher-kinded, type classes, implicits)

This Skill Does NOT Cover

  • General conversion methodology - see meta-convert-dev
  • Elm language fundamentals - see lang-elm-dev
  • Scala language fundamentals - see lang-scala-dev
  • ScalaJS specific patterns - see lang-scala-js-dev for frontend-to-frontend conversions

Quick Reference

| Elm | Scala | Notes | |-----|-------|-------| | type alias User = { name : String } | case class User(name: String) | Records → case classes | | type Msg = Increment \| Decrement | sealed trait Msg; case object Increment extends Msg | Union types → sealed traits | | Maybe a | Option[A] | Direct mapping with richer combinators | | Result error value | Either[Error, Value] | Direct mapping, right-biased | | List a | List[A] or Vector[A] | Lists or vectors | | Cmd Msg | IO[Unit] or Task[Unit] | Effects with cats-effect/ZIO | | case x of ... | x match { case ... => ... } | Pattern matching | | \x -> x + 1 | x => x + 1 or _ + 1 | Lambda syntax | | update : Msg -> Model -> (Model, Cmd Msg) | def update(model: Model, msg: Msg): (Model, IO[Unit]) | TEA → functional effects | | ( a, b ) | (A, B) (Tuple2) | Tuples with named accessors |


When Converting Code

  1. Analyze source thoroughly before writing target - understand TEA flow and data dependencies
  2. Map types first - create type equivalence table for domain models
  3. Preserve semantics over syntax similarity - leverage Scala's richer type system
  4. Adopt target idioms - don't write "Elm code in Scala syntax"
  5. Handle edge cases - Option chaining, Either composition, effect management
  6. Test equivalence - same inputs → same outputs
  7. Leverage type classes - use implicits for compile-time guarantees Elm lacks

Type System Mapping

Primitive Types

| Elm | Scala | Notes | |-----|-------|-------| | String | String | Direct mapping | | Int | Int | 32-bit integers | | Float | Double | Scala uses Double by default | | Bool | Boolean | Direct mapping | | Char | Char | Direct mapping | | () (unit) | Unit | Unit type, same semantics |

Collection Types

| Elm | Scala | Notes | |-----|-------|-------| | List a | List[A] | Immutable linked list (similar semantics) | | List a | Vector[A] | Better for indexed access (O(log n) vs O(n)) | | Array a | Vector[A] or Array[A] | Vector preferred for immutability | | ( a, b ) | (A, B) | Tuples, access via ._1, ._2 | | ( a, b, c ) | (A, B, C) | Scala supports tuples up to Tuple22 | | Dict k v | Map[K, V] | Immutable map | | Set a | Set[A] | Immutable set |

Composite Types

| Elm | Scala | Notes | |-----|-------|-------| | type alias User = { name : String } | case class User(name: String) | Case classes are idiomatic | | type Msg = A \| B | sealed trait Msg; case object A extends Msg; case object B extends Msg | Sealed trait ADTs | | type Msg = SetName String | sealed trait Msg; case class SetName(value: String) extends Msg | ADTs with data | | type Result err ok = Ok ok \| Err err | Either[Err, Ok] | Either is built-in, right-biased | | Maybe a | Option[A] | Option is built-in with Some/None |


Idiom Translation

Pattern: Union Types to Sealed Traits

Elm uses union types for discriminated unions. Scala uses sealed traits with case classes/objects.

Elm:

type Msg
    = Increment
    | Decrement
    | SetCount Int

update : Msg -> Model -> Model
update msg model =
    case msg of
        Increment ->
            { model | count = model.count + 1 }

        Decrement ->
            { model | count = model.count - 1 }

        SetCount newCount ->
            { model | count = newCount }

Scala:

// Sealed trait ensures exhaustive pattern matching
sealed trait Msg
case object Increment extends Msg
case object Decrement extends Msg
case class SetCount(value: Int) extends Msg

case class Model(count: Int)

def update(model: Model, msg: Msg): Model = msg match {
  case Increment => model.copy(count = model.count + 1)
  case Decrement => model.copy(count = model.count - 1)
  case SetCount(newCount) => model.copy(count = newCount)
}

Why this translation:

  • Sealed traits provide compile-time exhaustiveness checking like Elm
  • Case objects for singleton variants are lightweight
  • Case classes for variants with data provide automatic pattern matching
  • The copy method on case classes is similar to Elm's record update syntax

Pattern: Maybe to Option

Elm's Maybe type translates directly to Scala's Option with richer combinators.

Elm:

findUser : Int -> Maybe User
findUser id =
    if id == 1 then
        Just { name = "Alice", age = 30 }
    else
        Nothing

displayName : Maybe User -> String
displayName maybeUser =
    case maybeUser of
        Just user ->
            user.name

        Nothing ->
            "Anonymous"

-- Using Maybe.withDefault
name : String
name =
    findUser 1
        |> Maybe.map .name
        |> Maybe.withDefault "Anonymous"

Scala:

case class User(name: String, age: Int)

def findUser(id: Int): Option[User] = {
  if (id == 1) Some(User("Alice", 30))
  else None
}

def displayName(maybeUser: Option[User]): String = maybeUser match {
  case Some(user) => user.name
  case None => "Anonymous"
}

// Using Option combinators
val name: String =
  findUser(1)
    .map(_.name)
    .getOrElse("Anonymous")

// Or more idiomatically with fold
val name2: String =
  findUser(1).fold("Anonymous")(_.name)

Why this translation:

  • Option has the same semantics as Maybe
  • Scala's Option provides richer combinators (fold, orElse, collect, etc.)
  • Pattern matching syntax is similar but uses => instead of ->
  • getOrElse is equivalent to withDefault

Pattern: Result Type to Either

Elm's Result type maps to Scala's Either, which is right-biased for easy chaining.

Elm:

parseAge : String -> Result String Int
parseAge str =
    case String.toInt str of
        Just age ->
            if age >= 0 then
                Ok age
            else
                Err "Age must be non-negative"

        Nothing ->
            Err "Not a valid number"

-- Chain Results
validateAge : String -> Result String Int
validateAge str =
    parseAge str
        |> Result.andThen (\age ->
            if age < 120 then
                Ok age
            else
                Err "Age must be less than 120"
        )

Scala:

def parseAge(str: String): Either[String, Int] = {
  try {
    val age = str.toInt
    if (age >= 0) Right(age)
    else Left("Age must be non-negative")
  } catch {
    case _: NumberFormatException => Left("Not a valid number")
  }
}

// Chain Eithers with flatMap
def validateAge(str: String): Either[String, Int] = {
  parseAge(str).flatMap { age =>
    if (age < 120) Right(age)
    else Left("Age must be less than 120")
  }
}

// Or using for-comprehension (idiomatic)
def validateAge2(str: String): Either[String, Int] = for {
  age <- parseAge(str)
  validAge <- if (age < 120) Right(age)
              else Left("Age must be less than 120")
} yield validAge

Why this translation:

  • Either is right-biased, so flatMap/map operate on Right values
  • For-comprehensions make chaining more readable
  • Exception handling with try/catch is more idiomatic in Scala than creating helper parsers
  • Either provides the same type safety as Result

Pattern: The Elm Architecture to Functional Effects

TEA's Model-Update-View pattern translates to functional effect systems in Scala.

Elm:

-- MODEL
type alias Model =
    { count : Int }

init : Model
init =
    { count = 0 }

-- UPDATE
type Msg
    = Increment
    | Decrement

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Increment ->
            ( { model | count = model.count + 1 }, Cmd.none )

        Decrement ->
            ( { model | count = model.count - 1 }, Cmd.none )

-- VIEW
view : Model -> Html Msg
view model =
    div []
        [ button [ onClick Decrement ] [ text "-" ]
        , div [] [ text (String.fromInt model.count) ]
        , button [ onClick Increment ] [ text "+" ]
        ]

Scala (with cats-effect):

import cats.effect.IO
import cats.effect.concurrent.Ref

// MODEL
case class Model(count: Int)

def init: Model = Model(0)

// UPDATE
sealed trait Msg
case object Increment extends Msg
case object Decrement extends Msg

def update(model: Model, msg: Msg): (Model, IO[Unit]) = msg match {
  case Increment => (model.copy(count = model.count + 1), IO.unit)
  case Decrement => (model.copy(count = model.count - 1), IO.unit)
}

// Stateful version using Ref
def runApp: IO[Unit] = for {
  modelRef <- Ref.of[IO, Model](init)
  _ <- modelRef.update { model =>
    val (newModel, effect) = update(model, Increment)
    newModel
  }
  finalModel <- modelRef.get
  _ <- IO(println(s"Count: ${finalModel.count}"))
} yield ()

Scala (with ZIO):

import zio._

// MODEL
case class Model(count: Int)

// UPDATE
sealed trait Msg
case object Increment extends Msg
case object Decrement extends Msg

def update(model: Model, msg: Msg): (Model, Task[Unit]) = msg match {
  case Increment => (model.copy(count = model.count + 1), ZIO.unit)
  case Decrement => (model.copy(count = model.count - 1), ZIO.unit)
}

// Stateful version using Ref
def runApp: Task[Unit] = for {
  modelRef <- Ref.make(Model(0))
  _ <- modelRef.update { model =>
    val (newModel, effect) = update(model, Increment)
    newModel
  }
  finalModel <- modelRef.get
  _ <- Console.printLine(s"Count: ${finalModel.count}")
} yield ()

Why this translation:

  • IO/Task types represent side effects like Cmd in Elm
  • Ref provides mutable reference in pure FP (like Elm's managed state)
  • For-comprehensions sequence effects like Elm's Cmd.batch
  • Pattern separates pure logic (update) from effects

Pattern: List Operations

Elm and Scala share similar list APIs due to functional roots.

Elm:

-- Transform
List.map (\x -> x * 2) [1, 2, 3]
List.filter (\x -> x > 2) [1, 2, 3, 4]
List.concatMap (\x -> [x, x * 10]) [1, 2]

-- Reduce
List.foldl (+) 0 [1, 2, 3, 4]
List.foldr (::) [] [1, 2, 3]

-- Utilities
List.length [1, 2, 3]
List.head [1, 2, 3]  -- Maybe Int
List.tail [1, 2, 3]  -- Maybe (List Int)

Scala:

// Transform
List(1, 2, 3).map(_ * 2)
List(1, 2, 3, 4).filter(_ > 2)
List(1, 2).flatMap(x => List(x, x * 10))

// Reduce
List(1, 2, 3, 4).foldLeft(0)(_ + _)
List(1, 2, 3).foldRight(List.empty[Int])(_ :: _)

// Utilities
List(1, 2, 3).length
List(1, 2, 3).headOption  // Option[Int]
List(1, 2, 3).tail        // List[Int] (throws on empty!)
List(1, 2, 3).drop(1)     // Safe version of tail

Why this translation:

  • APIs are nearly identical due to shared FP heritage
  • Scala's flatMap is equivalent to Elm's concatMap
  • Use headOption instead of head for safety (returns Option)
  • tail throws exception on empty list - prefer drop(1) or tailOption (via extension)

Error Handling

Elm Error Model → Scala Error Model

Elm uses:

  • Maybe a for nullable values (explicit, no null)
  • Result error value for operations that can fail with context
  • No exceptions (compiler enforces handling)

Scala uses:

  • Option[A] for nullable values (explicit, but null still exists in Java interop)
  • Either[E, A] for operations that can fail with context
  • Try[A] for exception handling
  • Exceptions are available (but discouraged in FP)

Translation strategy:

| Elm Pattern | Scala Pattern | Notes | |-------------|---------------|-------| | Maybe a | Option[A] | Direct mapping | | Maybe.withDefault d m | m.getOrElse(d) | Extract with default | | Maybe.map f m | m.map(f) | Transform value | | Maybe.andThen f m | m.flatMap(f) | Chain operations | | Result err val | Either[Err, Val] | Direct mapping | | Result.map f r | r.map(f) | Transform right value | | Result.andThen f r | r.flatMap(f) | Chain operations | | Result.mapError f r | r.left.map(f) | Transform left (error) |

Advanced pattern: Accumulating errors

// Elm doesn't have built-in error accumulation
// Scala can use Validated from cats for this

import cats.data.Validated
import cats.implicits._

case class ValidationError(message: String)

def validateAge(age: Int): Validated[ValidationError, Int] = {
  if (age >= 0 && age < 120) age.valid
  else ValidationError("Invalid age").invalid
}

def validateName(name: String): Validated[ValidationError, String] = {
  if (name.nonEmpty) name.valid
  else ValidationError("Name is empty").invalid
}

// Accumulate errors (can't do this easily in Elm)
val result = (validateAge(-1), validateName("")).mapN { (age, name) =>
  User(name, age)
}
// Result: Invalid(ValidationError("Invalid age") + ValidationError("Name is empty"))

Concurrency Patterns

Elm Async → Scala Async

Elm uses:

  • Cmd Msg for side effects
  • Sub Msg for subscriptions
  • Task for composable async operations
  • No direct control over concurrency (runtime manages it)

Scala uses:

  • Future[A] - eager, implicit ExecutionContext
  • IO[A] (cats-effect) - lazy, explicit runtime
  • Task[A] (ZIO) - lazy, fiber-based
  • Stream[F, A] (fs2) - streaming effects

Translation strategies:

Simple HTTP Request

Elm:

type Msg = GotUser (Result Http.Error User)

getUser : Int -> Cmd Msg
getUser id =
    Http.get
        { url = "https://api.example.com/users/" ++ String.fromInt id
        , expect = Http.expectJson GotUser userDecoder
        }

Scala (with http4s + cats-effect):

import cats.effect.IO
import org.http4s.client.Client
import org.http4s.circe.CirceEntityDecoder._
import io.circe.generic.auto._

case class User(name: String, age: Int)

def getUser(id: Int)(implicit client: Client[IO]): IO[Either[Throwable, User]] = {
  client.expect[User](s"https://api.example.com/users/$id")
    .attempt
}

Concurrent Operations

Elm:

-- Elm doesn't expose concurrency primitives
-- Multiple Cmds are handled by the runtime
Cmd.batch
    [ fetchUser 1
    , fetchUser 2
    , fetchUser 3
    ]

Scala (cats-effect parallel):

import cats.effect.IO
import cats.syntax.parallel._

// Run requests in parallel
val users: IO[List[User]] = List(1, 2, 3)
  .parTraverse(id => getUser(id))

Scala (ZIO parallel):

import zio._

val users: Task[List[User]] = ZIO.collectAllPar(
  List(1, 2, 3).map(id => getUser(id))
)

Memory & Ownership

Both Elm and Scala run on garbage-collected runtimes:

  • Elm: Compiles to JavaScript, uses JS GC
  • Scala: Runs on JVM, uses JVM GC

Translation considerations:

  • No ownership concerns like Rust
  • Both use immutable data structures by default
  • Scala allows mutable collections but discouraged
  • Scala has more control over performance (lazy collections, views, iterators)

Performance patterns:

// Elm: Lists are always strict
List.map f (List.map g list)  -- Creates intermediate list

// Scala: Can optimize with views/iterators
list.view.map(f).map(g).toList  // No intermediate collection (Scala 2.13+)

// Or use LazyList for lazy evaluation
LazyList(1, 2, 3).map(f).map(g)  // Only computes on demand

Common Pitfalls

  1. Null values from Java interop: Elm has no null, but Scala inherits null from Java. Always wrap nullable Java values in Option.

    // BAD: Assumes non-null
    val name: String = javaObject.getName()  // Can be null!
    
    // GOOD: Wrap in Option
    val name: Option[String] = Option(javaObject.getName())
    
  2. Non-exhaustive pattern matching: Elm enforces exhaustiveness at compile-time. Scala only warns by default.

    // Enable fatal warnings in build.sbt
    scalacOptions += "-Xfatal-warnings"
    scalacOptions += "-Xlint:_"
    
    // Use sealed traits for exhaustive checking
    sealed trait Msg  // Compiler knows all subtypes
    
  3. Mutability creeping in: Elm is purely immutable. Scala allows var and mutable collections.

    // BAD: Mutable state
    var count = 0
    
    // GOOD: Immutable updates
    val count = 0
    val newCount = count + 1
    
  4. Exceptions instead of Either: Elm forces explicit error handling. Scala allows exceptions.

    // BAD: Throwing exceptions
    def divide(a: Int, b: Int): Int = {
      if (b == 0) throw new Exception("Division by zero")
      else a / b
    }
    
    // GOOD: Return Either
    def divide(a: Int, b: Int): Either[String, Int] = {
      if (b == 0) Left("Division by zero")
      else Right(a / b)
    }
    
  5. Future vs IO confusion: Future is eager and executes immediately. IO is lazy and needs explicit run.

    // EAGER: Executes on creation
    val future = Future { println("Running"); 42 }
    
    // LAZY: Only executes when explicitly run
    val io = IO { println("Running"); 42 }
    io.unsafeRunSync()  // Only now does it print
    
  6. Type inference differences: Elm infers everything. Scala sometimes needs help with higher-kinded types.

    // May need explicit type annotations
    def sequence[F[_]: Applicative, A](list: List[F[A]]): F[List[A]] = ...
    
  7. Pattern matching on List.tail: Scala's tail throws on empty list, unlike Elm.

    // BAD: Can throw exception
    val rest = list.tail
    
    // GOOD: Use pattern matching
    list match {
      case head :: tail => // Safe
      case Nil => // Handle empty
    }
    

Tooling

| Tool | Purpose | Notes | |------|---------|-------| | sbt | Build tool | Most common Scala build tool | | Scala CLI | Scripting | Quick scripts and REPLs | | scalac | Compiler | Scala compiler (usually via sbt) | | scalafmt | Code formatter | Like elm-format, auto-formats code | | scalafix | Linting/refactoring | Like elm-review, code quality | | Metals | LSP server | IDE support (VS Code, Vim, Emacs) | | IntelliJ IDEA | IDE | Full-featured Scala IDE | | ScalaTest | Testing | Most popular test framework | | ScalaCheck | Property testing | QuickCheck-style property tests | | cats | FP library | Type classes, data types | | cats-effect | Effect system | IO, concurrency primitives | | ZIO | Effect system | Alternative to cats-effect | | http4s | HTTP | Functional HTTP library | | circe | JSON | Pure FP JSON library |


Examples

Examples progress in complexity from simple type mappings to realistic applications.

Example 1: Simple - Type Alias to Case Class

Before (Elm):

type alias User =
    { name : String
    , email : String
    , age : Int
    }

createUser : String -> String -> Int -> User
createUser name email age =
    { name = name
    , email = email
    , age = age
    }

updateAge : User -> Int -> User
updateAge user newAge =
    { user | age = newAge }

After (Scala):

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

def createUser(name: String, email: String, age: Int): User =
  User(name, email, age)

def updateAge(user: User, newAge: Int): User =
  user.copy(age = newAge)

Example 2: Medium - Union Types and Pattern Matching

Before (Elm):

type Route
    = Home
    | Users
    | User Int
    | NotFound

type Msg
    = NavigateTo Route
    | FetchUsers
    | GotUsers (Result Http.Error (List User))

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NavigateTo route ->
            ( { model | currentRoute = route }
            , case route of
                Users ->
                    fetchUsers

                User id ->
                    fetchUser id

                _ ->
                    Cmd.none
            )

        FetchUsers ->
            ( model, fetchUsers )

        GotUsers (Ok users) ->
            ( { model | users = users }, Cmd.none )

        GotUsers (Err error) ->
            ( { model | error = Just (errorToString error) }, Cmd.none )

After (Scala):

import cats.effect.IO

sealed trait Route
case object Home extends Route
case object Users extends Route
case class User(id: Int) extends Route
case object NotFound extends Route

sealed trait Msg
case class NavigateTo(route: Route) extends Msg
case object FetchUsers extends Msg
case class GotUsers(result: Either[Throwable, List[UserData]]) extends Msg

case class UserData(name: String, email: String)
case class Model(
  currentRoute: Route,
  users: List[UserData],
  error: Option[String]
)

def update(model: Model, msg: Msg): (Model, IO[Unit]) = msg match {
  case NavigateTo(route) =>
    val effect = route match {
      case Users => fetchUsers
      case User(id) => fetchUser(id)
      case _ => IO.unit
    }
    (model.copy(currentRoute = route), effect)

  case FetchUsers =>
    (model, fetchUsers)

  case GotUsers(Right(users)) =>
    (model.copy(users = users), IO.unit)

  case GotUsers(Left(error)) =>
    (model.copy(error = Some(error.getMessage)), IO.unit)
}

// Placeholder effects
def fetchUsers: IO[Unit] = IO.unit
def fetchUser(id: Int): IO[Unit] = IO.unit

Example 3: Complex - Complete TEA Application

Before (Elm):

module Main exposing (main)

import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Http
import Json.Decode as Decode

-- MODEL

type alias Model =
    { query : String
    , results : List SearchResult
    , status : Status
    }

type Status
    = Loading
    | Success
    | Failure String

type alias SearchResult =
    { title : String
    , url : String
    , snippet : String
    }

init : () -> ( Model, Cmd Msg )
init _ =
    ( { query = ""
      , results = []
      , status = Success
      }
    , Cmd.none
    )

-- UPDATE

type Msg
    = UpdateQuery String
    | Search
    | GotResults (Result Http.Error (List SearchResult))

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        UpdateQuery newQuery ->
            ( { model | query = newQuery }, Cmd.none )

        Search ->
            ( { model | status = Loading }
            , searchApi model.query
            )

        GotResults (Ok results) ->
            ( { model | results = results, status = Success }
            , Cmd.none
            )

        GotResults (Err error) ->
            ( { model | status = Failure (errorToString error) }
            , Cmd.none
            )

-- HTTP

searchApi : String -> Cmd Msg
searchApi query =
    Http.get
        { url = "https://api.example.com/search?q=" ++ query
        , expect = Http.expectJson GotResults resultsDecoder
        }

resultsDecoder : Decode.Decoder (List SearchResult)
resultsDecoder =
    Decode.list <|
        Decode.map3 SearchResult
            (Decode.field "title" Decode.string)
            (Decode.field "url" Decode.string)
            (Decode.field "snippet" Decode.string)

errorToString : Http.Error -> String
errorToString error =
    case error of
        Http.BadUrl url ->
            "Bad URL: " ++ url

        Http.Timeout ->
            "Request timeout"

        Http.NetworkError ->
            "Network error"

        Http.BadStatus status ->
            "Bad status: " ++ String.fromInt status

        Http.BadBody body ->
            "Bad body: " ++ body

-- VIEW

view : Model -> Html Msg
view model =
    div [ class "container" ]
        [ h1 [] [ text "Search Engine" ]
        , div [ class "search-box" ]
            [ input
                [ type_ "text"
                , placeholder "Enter search query"
                , value model.query
                , onInput UpdateQuery
                ]
                []
            , button [ onClick Search ] [ text "Search" ]
            ]
        , viewStatus model.status
        , div [ class "results" ]
            (List.map viewResult model.results)
        ]

viewStatus : Status -> Html Msg
viewStatus status =
    case status of
        Loading ->
            div [ class "loading" ] [ text "Loading..." ]

        Success ->
            text ""

        Failure error ->
            div [ class "error" ] [ text error ]

viewResult : SearchResult -> Html Msg
viewResult result =
    div [ class "result" ]
        [ h3 [] [ a [ href result.url ] [ text result.title ] ]
        , p [] [ text result.snippet ]
        ]

-- MAIN

main : Program () Model Msg
main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = \_ -> Sub.none
        }

After (Scala with cats-effect and http4s):

import cats.effect._
import cats.effect.concurrent.Ref
import io.circe.generic.auto._
import org.http4s._
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.client.Client

// MODEL

case class Model(
  query: String,
  results: List[SearchResult],
  status: Status
)

sealed trait Status
case object Loading extends Status
case object Success extends Status
case class Failure(error: String) extends Status

case class SearchResult(
  title: String,
  url: String,
  snippet: String
)

def init: Model = Model(
  query = "",
  results = List.empty,
  status = Success
)

// UPDATE

sealed trait Msg
case class UpdateQuery(newQuery: String) extends Msg
case object Search extends Msg
case class GotResults(result: Either[Throwable, List[SearchResult]]) extends Msg

def update(model: Model, msg: Msg)(implicit client: Client[IO]): (Model, IO[Unit]) = msg match {
  case UpdateQuery(newQuery) =>
    (model.copy(query = newQuery), IO.unit)

  case Search =>
    val effect = searchApi(model.query).flatMap { result =>
      processMsg(GotResults(result))
    }
    (model.copy(status = Loading), effect)

  case GotResults(Right(results)) =>
    (model.copy(results = results, status = Success), IO.unit)

  case GotResults(Left(error)) =>
    (model.copy(status = Failure(error.getMessage)), IO.unit)
}

// HTTP

def searchApi(query: String)(implicit client: Client[IO]): IO[Either[Throwable, List[SearchResult]]] = {
  val uri = Uri.unsafeFromString(s"https://api.example.com/search?q=$query")
  client.expect[List[SearchResult]](uri).attempt
}

// APPLICATION RUNTIME

def runApp(implicit client: Client[IO]): IO[Unit] = for {
  // Create mutable reference for model
  modelRef <- Ref.of[IO, Model](init)

  // Example: Simulate user actions
  _ <- processMsg(UpdateQuery("functional programming")).flatMap { msg =>
    modelRef.update { model =>
      val (newModel, effect) = update(model, msg)
      // Run effect in background
      effect.unsafeRunAsync(_ => ())
      newModel
    }
  }

  _ <- processMsg(Search).flatMap { msg =>
    modelRef.update { model =>
      val (newModel, effect) = update(model, msg)
      effect.unsafeRunAsync(_ => ())
      newModel
    }
  }

  // Get final model
  finalModel <- modelRef.get
  _ <- IO(println(s"Final model: $finalModel"))
} yield ()

// Helper to process messages
def processMsg(msg: Msg): IO[Msg] = IO.pure(msg)

// In a real application, you would integrate with a web framework
// like http4s for server-side rendering, or ScalaJS + Laminar for frontend

Notes on the complex example:

  • Scala version separates pure logic (update function) from effects
  • IO type represents side effects, making them explicit like Cmd in Elm
  • Ref provides mutable reference in pure FP context
  • In production, you'd use a web framework (http4s, ZIO HTTP) or frontend library (ScalaJS + Laminar, Outwatch)
  • The pattern preserves TEA's separation of concerns: Model, Update, Effects

See Also

For more examples and patterns, see:

  • meta-convert-dev - Foundational patterns with cross-language examples
  • convert-elm-clojure - Related conversion (Elm → dynamic FP)
  • lang-elm-dev - Elm development patterns
  • lang-scala-dev - Scala development patterns
  • lang-scala-cats-dev - Cats library for advanced FP
  • lang-scala-zio-dev - ZIO effect system
  • lang-scala-js-dev - ScalaJS for frontend (if staying in browser)

Cross-cutting pattern skills:

  • patterns-concurrency-dev - Async, channels, threads across languages
  • patterns-serialization-dev - JSON, validation across languages
  • patterns-metaprogramming-dev - Macros, implicits, type-level programming