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 - F# web frameworks in depth - see framework-specific skills
Quick Reference
| Elm | F# | 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 Elmish | Framework-specific |
| Html msg | Elmish.React ReactElement | Frontend framework |
When Converting Code
- Analyze source thoroughly before writing target
- Map types first - Elm and F# type systems are very similar
- Preserve functional purity - both languages emphasize immutability
- Adopt F# idioms - leverage .NET libraries and computation expressions
- Handle The Elm Architecture - map to Elmish or custom MVU implementation
- Test equivalence - same inputs → same outputs
Type System Mapping
Primitive Types
| Elm | F# | 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/A | Elm's impossible type; F# doesn't have exact equivalent |
Collection Types
| Elm | F# | 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 | Tuple | Same syntax: (a, b) |
Composite Types
| Elm | F# | Notes |
|-----|-----|-------|
| type alias | type (type alias) | type Person = { Name: string; Age: int } |
| type (union) | type (discriminated union) | Same concept, slightly different syntax |
| Record | Record | Same semantics, nearly identical syntax |
| Opaque type | Single-case union | type Email = Email of string |
Option/Maybe Types
| Elm | F# | 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
| Elm | F# | 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
| Elm | F# | 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 equivalent | Manual 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→optionis a direct semantic mappingJust→Some,Nothing→NoneMaybe.withDefault→Option.defaultValueList.head : List a -> Maybe a→List.tryFindorList.tryHead- F# has richer
Optionmodule 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 parametersOk/Err→Ok/ErrorResult.andThen→Result.bind- F# uses
TryParsepattern for parsing (returns tuplebool * 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
ofkeyword 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
Cmdtype is similar but uses F# async under the hood- View function receives
dispatchexplicitly 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
Maybefields →optionwith 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:
- Option/Result types (recommended for Elm migrations)
- Exceptions (for interop with .NET libraries)
- 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
-
Type Parameter Order in Result
- Elm:
Result error value(error first) - F#:
Result<'value, 'error>(value first) - Always double-check when converting Result types!
- Elm:
-
Constructor Names
- Elm:
Just,Nothing,Err - F#:
Some,None,Error - Remember to rename when converting
- Elm:
-
Record Update Syntax
- Elm:
{ model | field = value } - F#:
{ model with Field = value } - Different keywords:
|vswith
- Elm:
-
Union Case Syntax
- Elm:
Type arg - F#:
Type of arg - F# requires
ofkeyword
- Elm:
-
List vs Array Performance
- Both Elm and F#
listare immutable linked lists - F# also has mutable
arrayfor performance-critical code - Prefer
listfor Elm semantics, considerarrayfor hot paths
- Both Elm and F#
-
Module Qualification
- Elm:
List.map,String.toInt - F#:
List.map,System.Int32.Parse - F# may require fully qualified names for .NET types
- Elm:
-
Null Safety
- Elm: No null, ever
- F#:
nullexists for .NET interop - Use
Option.ofObjto convert nullable .NET values tooption
-
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
| Tool | Purpose | Notes | |------|---------|-------| | Fable | F# → JavaScript compiler | Compile F# to JS like Elm compiles to JS | | Elmish | TEA implementation for F# | Official F# implementation of Elm Architecture | | Elmish.React | React bindings for Elmish | Render views using React | | Thoth.Json | JSON decoding like Elm | Elm-style decoders for F# | | Feliz | Modern F# React DSL | Alternative to Elmish.React | | Ionide | F# IDE support | VS Code extension for F# | | Fantomas | F# code formatter | Like elm-format | | FsCheck | Property-based testing | Like 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 exampleslang-elm-dev- Elm development patterns and The Elm Architecturelang-fsharp-dev- F# development patterns and functional programmingpatterns-concurrency-dev- Async patterns across languagespatterns-serialization-dev- JSON handling across languages