Claude-skill-registry convert-elm-fsharp

Convert Elm code to idiomatic F#. Use when migrating Elm applications to F#, translating Elm's Model-View-Update pattern to F# patterns, or refactoring Elm codebases to leverage .NET ecosystem. Extends meta-convert-dev with Elm-to-F# specific patterns.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/convert-elm-fsharp" ~/.claude/skills/majiayu000-claude-skill-registry-convert-elm-fsharp && rm -rf "$T"
manifest: skills/data/convert-elm-fsharp/SKILL.md
source content

Convert Elm to F#

Convert Elm code to idiomatic F#. This skill extends

meta-convert-dev
with Elm-to-F# specific type mappings, idiom translations, and tooling for migrating functional frontend code to the .NET ecosystem.

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 types → F# types
  • Idiom translations: Elm patterns → idiomatic F#
  • Error handling: Elm Result/Maybe → F# Result/Option
  • Architecture: The Elm Architecture (TEA) → F# Elmish or MVU patterns
  • Functional patterns: Pure functions, immutability preserved in F#

This Skill Does NOT Cover

  • General conversion methodology - see
    meta-convert-dev
  • Elm language fundamentals - see
    lang-elm-dev
  • F# language fundamentals - see
    lang-fsharp-dev
  • Reverse conversion (F# → Elm) - see
    convert-fsharp-elm
  • F# web frameworks in depth - see framework-specific skills

Quick Reference

ElmF#Notes
String
string
Direct mapping
Int
int
32-bit signed integer
Float
float
64-bit floating point
Bool
bool
Direct mapping
List a
'a list
Immutable linked list
Array a
'a array
Mutable fixed-size array
Maybe a
'a option
Same semantics: Some/None
Result error value
Result<'value, 'error>
Same semantics: Ok/Error
type alias
type
(type alias)
Direct mapping
type
(union)
type
(discriminated union)
Direct mapping
Cmd msg
Async<'msg>
or Elmish
Cmd<'msg>
Depends on framework
Sub msg
Event subscriptions in ElmishFramework-specific
Html msg
Elmish.React
ReactElement
Frontend framework

When Converting Code

  1. Analyze source thoroughly before writing target
  2. Map types first - Elm and F# type systems are very similar
  3. Preserve functional purity - both languages emphasize immutability
  4. Adopt F# idioms - leverage .NET libraries and computation expressions
  5. Handle The Elm Architecture - map to Elmish or custom MVU implementation
  6. Test equivalence - same inputs → same outputs

Type System Mapping

Primitive Types

ElmF#Notes
String
string
UTF-16 in F# (via .NET), UTF-8 in Elm
Int
int
Both 32-bit signed
Float
float
F#
float
is 64-bit (alias for
double
)
Bool
bool
Direct mapping
Char
char
Single character
()
unit
Unit type (void)
never
N/AElm's impossible type; F# doesn't have exact equivalent

Collection Types

ElmF#Notes
List a
'a list
Immutable linked list, same semantics
Array a
'a array
F# arrays are mutable but similar performance
Set a
Set<'a>
Immutable set in both
Dict k v
Map<'k, 'v>
Immutable map; F# also has
Dictionary<'k,'v>
(mutable)
Tuple
TupleSame syntax:
(a, b)

Composite Types

ElmF#Notes
type alias
type
(type alias)
type Person = { Name: string; Age: int }
type
(union)
type
(discriminated union)
Same concept, slightly different syntax
RecordRecordSame semantics, nearly identical syntax
Opaque typeSingle-case union
type Email = Email of string

Option/Maybe Types

ElmF#Notes
Maybe a
'a option
Both have
Some
/
Just
and
None
/
Nothing
Just x
Some x
Constructor name differs
Nothing
None
Constructor name differs
Maybe.withDefault
Option.defaultValue
Same semantics
Maybe.map
Option.map
Same semantics
Maybe.andThen
Option.bind
Monadic bind

Result Types

ElmF#Notes
Result error value
Result<'value, 'error>
Type parameters in reverse order!
Ok value
Ok value
Same constructor
Err error
Error error
F# uses
Error
, not
Err
Result.map
Result.map
Same semantics
Result.andThen
Result.bind
Monadic bind
Result.withDefault
Result.defaultValue
Same semantics

Critical Note: Elm's

Result error value
has error first, but F#
Result<'T, 'TError>
has value first!

Generic Type Parameters

ElmF#Notes
a
,
b
,
c
'a
,
'b
,
'c
F# uses single quote prefix
comparable
'a when 'a : comparison
Constraint syntax differs
number
^a when ^a : (static member (+) : ^a * ^a -> ^a)
F# uses SRTPs (complex)
appendable
No built-in equivalentManual trait constraints

Idiom Translation

Pattern 1: Maybe/Option Handling

Elm:

type alias User =
    { name : String
    , email : Maybe String
    }

getUserEmail : User -> String
getUserEmail user =
    Maybe.withDefault "No email" user.email

findUser : Int -> List User -> Maybe User
findUser id users =
    List.filter (\u -> u.id == id) users
        |> List.head

F#:

type User = {
    Name: string
    Email: string option
}

let getUserEmail (user: User) : string =
    user.Email |> Option.defaultValue "No email"

let findUser (id: int) (users: User list) : User option =
    users |> List.tryFind (fun u -> u.Id = id)

Why this translation:

  • Maybe
    option
    is a direct semantic mapping
  • Just
    Some
    ,
    Nothing
    None
  • Maybe.withDefault
    Option.defaultValue
  • List.head : List a -> Maybe a
    List.tryFind
    or
    List.tryHead
  • F# has richer
    Option
    module with more combinators

Pattern 2: Result/Error Handling

Elm:

type alias Error = String

parseAge : String -> Result Error 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 integer"

validateUser : String -> String -> Result Error User
validateUser name ageStr =
    parseAge ageStr
        |> Result.andThen (\age -> Ok { name = name, age = age })

F#:

type Error = string

let parseAge (str: string) : Result<int, Error> =
    match System.Int32.TryParse(str) with
    | true, age when age >= 0 ->
        Ok age
    | true, _ ->
        Error "Age must be non-negative"
    | false, _ ->
        Error "Not a valid integer"

let validateUser (name: string) (ageStr: string) : Result<User, Error> =
    parseAge ageStr
    |> Result.bind (fun age -> Ok { Name = name; Age = age })

Why this translation:

  • Result error value
    (Elm) →
    Result<'value, 'error>
    (F#) - note reversed type parameters
  • Ok
    /
    Err
    Ok
    /
    Error
  • Result.andThen
    Result.bind
  • F# uses
    TryParse
    pattern for parsing (returns tuple
    bool * value
    )
  • Both support railway-oriented programming with bind/map

Pattern 3: Union Types and Pattern Matching

Elm:

type Msg
    = Increment
    | Decrement
    | SetValue Int
    | Reset

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

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

        SetValue n ->
            { model | count = n }

        Reset ->
            { model | count = 0 }

F#:

type Msg =
    | Increment
    | Decrement
    | SetValue of int
    | Reset

let update (msg: Msg) (model: Model) : Model =
    match msg with
    | Increment ->
        { model with Count = model.Count + 1 }

    | Decrement ->
        { model with Count = model.Count - 1 }

    | SetValue n ->
        { model with Count = n }

    | Reset ->
        { model with Count = 0 }

Why this translation:

  • Discriminated unions are nearly identical
  • Elm:
    Type arg
    → F#:
    Type of arg
  • Record update syntax:
    { model | field = value }
    { model with Field = value }
  • Pattern matching syntax is nearly identical
  • F# requires explicit
    of
    keyword for union cases with data

Pattern 4: List Operations

Elm:

users : List User
users =
    [ { name = "Alice", age = 30 }
    , { name = "Bob", age = 25 }
    ]

activeUsers : List User -> List User
activeUsers =
    List.filter .active
        >> List.map .name
        >> List.sort

totalAge : List User -> Int
totalAge users =
    List.foldl (\user sum -> sum + user.age) 0 users

F#:

let users: User list =
    [ { Name = "Alice"; Age = 30 }
      { Name = "Bob"; Age = 25 } ]

let activeUsers: User list -> string list =
    List.filter (fun u -> u.Active)
    >> List.map (fun u -> u.Name)
    >> List.sort

let totalAge (users: User list) : int =
    users |> List.fold (fun sum user -> sum + user.Age) 0

Why this translation:

  • List syntax nearly identical:
    [ items ]
  • Function composition:
    >>
    in both languages
  • List.foldl
    List.fold
    (F# fold is left-associative by default)
  • F# has more list functions (
    List.sumBy
    ,
    List.groupBy
    , etc.)
  • Record access:
    .field
    (fun r -> r.Field)
    or use lambda

Pattern 5: The Elm Architecture (TEA) → F# Elmish

Elm:

type alias Model =
    { count : Int }

type Msg
    = Increment
    | Decrement

init : ( Model, Cmd Msg )
init =
    ( { count = 0 }, Cmd.none )

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 : Model -> Html Msg
view model =
    div []
        [ button [ onClick Decrement ] [ text "-" ]
        , text (String.fromInt model.count)
        , button [ onClick Increment ] [ text "+" ]
        ]

F# (using Elmish):

open Elmish

type Model = { Count: int }

type Msg =
    | Increment
    | Decrement

let init () : Model * Cmd<Msg> =
    { Count = 0 }, Cmd.none

let update (msg: Msg) (model: Model) : Model * Cmd<Msg> =
    match msg with
    | Increment ->
        { model with Count = model.Count + 1 }, Cmd.none

    | Decrement ->
        { model with Count = model.Count - 1 }, Cmd.none

let view (model: Model) (dispatch: Msg -> unit) =
    div [] [
        button [ OnClick (fun _ -> dispatch Decrement) ] [ str "-" ]
        str (string model.Count)
        button [ OnClick (fun _ -> dispatch Increment) ] [ str "+" ]
    ]

Why this translation:

  • Elmish is F#'s implementation of The Elm Architecture
  • Model, Msg, init, update, view pattern is identical
  • Cmd
    type is similar but uses F# async under the hood
  • View function receives
    dispatch
    explicitly in F#
  • Html msg
    ReactElement
    (via Fable.React)

Pattern 6: JSON Decoding

Elm:

import Json.Decode as Decode

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

userDecoder : Decode.Decoder User
userDecoder =
    Decode.map3 User
        (Decode.field "id" Decode.int)
        (Decode.field "name" Decode.string)
        (Decode.maybe (Decode.field "email" Decode.string))

F#:

open System.Text.Json
open System.Text.Json.Serialization

type User = {
    [<JsonPropertyName("id")>]
    Id: int

    [<JsonPropertyName("name")>]
    Name: string

    [<JsonPropertyName("email")>]
    Email: string option
}

// Automatic with System.Text.Json or Thoth.Json
let parseUser (json: string) : Result<User, string> =
    try
        JsonSerializer.Deserialize<User>(json) |> Ok
    with ex ->
        Error ex.Message

// Or with Thoth.Json for Elm-style decoders
open Thoth.Json.Net

let userDecoder : Decoder<User> =
    Decode.object (fun get -> {
        Id = get.Required.Field "id" Decode.int
        Name = get.Required.Field "name" Decode.string
        Email = get.Optional.Field "email" Decode.string
    })

Why this translation:

  • F# offers two approaches: attribute-based (simpler) or decoder-based (like Elm)
  • Thoth.Json provides Elm-style decoders for F#
  • System.Text.Json uses attributes and reflection
  • Maybe
    fields →
    option
    with proper serialization handling
  • F# has more serialization libraries available (.NET ecosystem)

Error Handling

Elm's Error Model

Elm guarantees no runtime exceptions through its type system:

  • All errors are encoded in types (
    Maybe
    ,
    Result
    )
  • Impossible states are made impossible via union types
  • Compiler enforces exhaustive pattern matching

F#'s Error Model

F# has multiple error handling approaches:

  1. Option/Result types (recommended for Elm migrations)
  2. Exceptions (for interop with .NET libraries)
  3. Computation expressions (for error workflows)

Migration Strategy

Preserve Elm's error safety:

// Use Result for expected errors
type ValidationError =
    | EmptyName
    | InvalidEmail
    | AgeTooYoung

let validateUser name email age : Result<User, ValidationError> =
    if String.IsNullOrWhiteSpace(name) then
        Error EmptyName
    elif not (email.Contains("@")) then
        Error InvalidEmail
    elif age < 18 then
        Error AgeTooYoung
    else
        Ok { Name = name; Email = email; Age = age }

// Chain validations
let createUser name email age =
    result {
        let! validatedUser = validateUser name email age
        let! savedUser = saveToDatabase validatedUser
        return savedUser
    }

Handle .NET exceptions when necessary:

// Wrap .NET APIs that throw exceptions
let safeParse (str: string) : Result<int, string> =
    try
        int str |> Ok
    with
    | :? System.FormatException -> Error "Invalid format"
    | :? System.OverflowException -> Error "Number too large"
    | ex -> Error ex.Message

Concurrency Patterns

Elm: Cmd and Tasks

Elm uses

Cmd
for side effects and
Task
for asynchronous operations:

type Msg
    = GotUsers (Result Http.Error (List User))

fetchUsers : Cmd Msg
fetchUsers =
    Http.get
        { url = "https://api.example.com/users"
        , expect = Http.expectJson GotUsers usersDecoder
        }

F#: Async and Elmish Commands

F# uses

Async<'T>
for asynchronous operations and Elmish
Cmd<'Msg>
:

type Msg =
    | GotUsers of Result<User list, string>

let fetchUsers : Cmd<Msg> =
    Cmd.OfAsync.perform
        (fun () -> async {
            let! response = Http.get "https://api.example.com/users"
            return! parseUsers response
        })
        ()
        (fun users -> GotUsers (Ok users))
        (fun ex -> GotUsers (Error ex.Message))

// Or with computation expression
let fetchUsersAsync () : Async<Result<User list, string>> =
    async {
        try
            let! response = Http.AsyncGet("https://api.example.com/users")
            let! users = parseUsersAsync response
            return Ok users
        with ex ->
            return Error ex.Message
    }

Why this translation:

  • Cmd msg
    Cmd<'msg>
    (Elmish)
  • Task
    Async<'T>
    (F# async workflow)
  • Elmish provides helpers like
    Cmd.OfAsync.perform
  • F# async is more powerful but requires explicit error handling

Common Pitfalls

  1. Type Parameter Order in Result

    • Elm:
      Result error value
      (error first)
    • F#:
      Result<'value, 'error>
      (value first)
    • Always double-check when converting Result types!
  2. Constructor Names

    • Elm:
      Just
      ,
      Nothing
      ,
      Err
    • F#:
      Some
      ,
      None
      ,
      Error
    • Remember to rename when converting
  3. Record Update Syntax

    • Elm:
      { model | field = value }
    • F#:
      { model with Field = value }
    • Different keywords:
      |
      vs
      with
  4. Union Case Syntax

    • Elm:
      Type arg
    • F#:
      Type of arg
    • F# requires
      of
      keyword
  5. List vs Array Performance

    • Both Elm and F#
      list
      are immutable linked lists
    • F# also has mutable
      array
      for performance-critical code
    • Prefer
      list
      for Elm semantics, consider
      array
      for hot paths
  6. Module Qualification

    • Elm:
      List.map
      ,
      String.toInt
    • F#:
      List.map
      ,
      System.Int32.Parse
    • F# may require fully qualified names for .NET types
  7. Null Safety

    • Elm: No null, ever
    • F#:
      null
      exists for .NET interop
    • Use
      Option.ofObj
      to convert nullable .NET values to
      option
  8. Capitalization Conventions

    • Elm: camelCase for everything except types
    • F#: PascalCase for types and record fields, camelCase for values
    • Must rename fields when converting records

Tooling

ToolPurposeNotes
FableF# → JavaScript compilerCompile F# to JS like Elm compiles to JS
ElmishTEA implementation for F#Official F# implementation of Elm Architecture
Elmish.ReactReact bindings for ElmishRender views using React
Thoth.JsonJSON decoding like ElmElm-style decoders for F#
FelizModern F# React DSLAlternative to Elmish.React
IonideF# IDE supportVS Code extension for F#
FantomasF# code formatterLike elm-format
FsCheckProperty-based testingLike Elm's fuzz testing

Examples

Example 1: Simple - Type and Function

Before (Elm):

type alias Point =
    { x : Float
    , y : Float
    }

distance : Point -> Point -> Float
distance p1 p2 =
    let
        dx = p1.x - p2.x
        dy = p1.y - p2.y
    in
    sqrt (dx * dx + dy * dy)

After (F#):

type Point = {
    X: float
    Y: float
}

let distance (p1: Point) (p2: Point) : float =
    let dx = p1.X - p2.X
    let dy = p1.Y - p2.Y
    sqrt (dx * dx + dy * dy)

Example 2: Medium - Union Types and Pattern Matching

Before (Elm):

type Tree a
    = Empty
    | Node a (Tree a) (Tree a)

depth : Tree a -> Int
depth tree =
    case tree of
        Empty ->
            0

        Node _ left right ->
            1 + max (depth left) (depth right)

mapTree : (a -> b) -> Tree a -> Tree b
mapTree f tree =
    case tree of
        Empty ->
            Empty

        Node value left right ->
            Node (f value) (mapTree f left) (mapTree f right)

After (F#):

type Tree<'a> =
    | Empty
    | Node of 'a * Tree<'a> * Tree<'a>

let rec depth (tree: Tree<'a>) : int =
    match tree with
    | Empty ->
        0

    | Node (_, left, right) ->
        1 + max (depth left) (depth right)

let rec mapTree (f: 'a -> 'b) (tree: Tree<'a>) : Tree<'b> =
    match tree with
    | Empty ->
        Empty

    | Node (value, left, right) ->
        Node (f value, mapTree f left, mapTree f right)

Example 3: Complex - The Elm Architecture with HTTP

Before (Elm):

module Main exposing (main)

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

type alias Model =
    { users : List User
    , loading : Bool
    , error : Maybe String
    }

type alias User =
    { id : Int
    , name : String
    }

type Msg
    = LoadUsers
    | GotUsers (Result Http.Error (List User))

init : () -> ( Model, Cmd Msg )
init _ =
    ( { users = [], loading = False, error = Nothing }
    , Cmd.none
    )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        LoadUsers ->
            ( { model | loading = True, error = Nothing }
            , fetchUsers
            )

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

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

view : Model -> Html Msg
view model =
    div []
        [ button [ onClick LoadUsers ] [ text "Load Users" ]
        , if model.loading then
            text "Loading..."
          else
            div []
                [ viewError model.error
                , viewUsers model.users
                ]
        ]

viewError : Maybe String -> Html Msg
viewError error =
    case error of
        Just err ->
            div [] [ text ("Error: " ++ err) ]

        Nothing ->
            text ""

viewUsers : List User -> Html Msg
viewUsers users =
    ul [] (List.map viewUser users)

viewUser : User -> Html Msg
viewUser user =
    li [] [ text user.name ]

fetchUsers : Cmd Msg
fetchUsers =
    Http.get
        { url = "https://api.example.com/users"
        , expect = Http.expectJson GotUsers usersDecoder
        }

usersDecoder : Decode.Decoder (List User)
usersDecoder =
    Decode.list userDecoder

userDecoder : Decode.Decoder User
userDecoder =
    Decode.map2 User
        (Decode.field "id" Decode.int)
        (Decode.field "name" Decode.string)

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

        Http.Timeout ->
            "Timeout"

        Http.NetworkError ->
            "Network error"

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

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

main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = \_ -> Sub.none
        }

After (F#):

module Main

open Elmish
open Elmish.React
open Fable.React
open Fable.React.Props
open Thoth.Json.Decode
open Fable.SimpleHttp

type Model = {
    Users: User list
    Loading: bool
    Error: string option
}

type User = {
    Id: int
    Name: string
}

type Msg =
    | LoadUsers
    | GotUsers of Result<User list, string>

let init () : Model * Cmd<Msg> =
    { Users = []; Loading = false; Error = None }, Cmd.none

let update (msg: Msg) (model: Model) : Model * Cmd<Msg> =
    match msg with
    | LoadUsers ->
        { model with Loading = true; Error = None }, fetchUsers ()

    | GotUsers (Ok users) ->
        { model with Users = users; Loading = false }, Cmd.none

    | GotUsers (Error error) ->
        { model with Loading = false; Error = Some error }, Cmd.none

let view (model: Model) (dispatch: Msg -> unit) =
    div [] [
        button [ OnClick (fun _ -> dispatch LoadUsers) ] [ str "Load Users" ]
        if model.Loading then
            str "Loading..."
        else
            div [] [
                viewError model.Error
                viewUsers model.Users
            ]
    ]

let viewError (error: string option) =
    match error with
    | Some err ->
        div [] [ str ("Error: " + err) ]
    | None ->
        str ""

let viewUsers (users: User list) =
    ul [] (users |> List.map viewUser)

let viewUser (user: User) =
    li [] [ str user.Name ]

let fetchUsers () : Cmd<Msg> =
    Cmd.OfAsync.perform
        (fun () -> async {
            let! response = Http.get "https://api.example.com/users"
            match response.statusCode with
            | 200 ->
                match Decode.fromString usersDecoder response.responseText with
                | Ok users -> return Ok users
                | Error err -> return Error err
            | code ->
                return Error $"Bad status: {code}"
        })
        ()
        GotUsers
        (fun ex -> GotUsers (Error ex.Message))

let userDecoder : Decoder<User> =
    Decode.object (fun get -> {
        Id = get.Required.Field "id" Decode.int
        Name = get.Required.Field "name" Decode.string
    })

let usersDecoder : Decoder<User list> =
    Decode.list userDecoder

Program.mkProgram init update view
|> Program.withReactSynchronous "root"
|> Program.run

See Also

For more examples and patterns, see:

  • meta-convert-dev
    - Foundational conversion patterns with cross-language examples
  • lang-elm-dev
    - Elm development patterns and The Elm Architecture
  • lang-fsharp-dev
    - F# development patterns and functional programming
  • patterns-concurrency-dev
    - Async patterns across languages
  • patterns-serialization-dev
    - JSON handling across languages