Convert Python to Elm
Convert Python code to idiomatic Elm for type-safe frontend applications. This skill extends meta-convert-dev with Python-to-Elm specific type mappings, idiom translations, and patterns for transforming dynamic, imperative Python code into functional, purely-functional Elm code with The Elm Architecture.
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: Python types → Elm types (dynamic → static, runtime → compile-time)
- Idiom translations: Imperative/OOP Python → functional-first Elm
- Error handling: try/except → Result/Maybe with pattern matching
- Concurrency: Python async/threading → The Elm Architecture (Cmd/Sub)
- Metaprogramming: Python decorators/metaclasses → elm-codegen and derivation patterns
- Architecture translation: Any Python pattern → The Elm Architecture (TEA)
- Platform differences: Backend/CLI Python → Frontend browser-based Elm
- No runtime exceptions: All errors handled at compile-time or through types
- JSON serialization: Pydantic → Json.Decode/Json.Encode pipelines
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Python language fundamentals - see
lang-python-dev - Elm language fundamentals - see
lang-elm-dev - Reverse conversion (Elm → Python) - not typically needed
- Fable.Python (F# to Python via Fable) - different toolchain
Quick Reference
| Python | Elm | Notes |
|---|---|---|
int |
Int |
Elm Int is JavaScript number (53-bit precision) |
float |
Float |
IEEE 754 double precision |
bool |
Bool |
Direct mapping |
str |
String |
UTF-8 in Elm vs Python's unicode |
bytes |
- | No direct equivalent; use String or Array Int |
list[T] |
List a |
Immutable linked list |
tuple |
( a, b ) / ( a, b, c ) |
Max 3-tuple in Elm |
dict[K, V] |
Dict comparable v |
Key must be comparable |
set[T] |
Set comparable |
Value must be comparable |
None |
Nothing (in Maybe a) |
Explicit optional |
Union[T, U] |
type X = A T | B U |
Discriminated union |
Callable |
a -> b |
Function type |
async def |
Cmd Msg / Task error value |
No async/await; effects at edges |
@dataclass |
type alias Record = { } |
Records with structural equality |
try/except |
Result error value |
No exceptions; use Result/Maybe |
class |
N/A (use TEA) | No OOP; use Model-Update-View pattern |
When Converting Code
- Analyze source thoroughly before writing target - understand data flow
- Map types first - create type equivalence table
- Identify side effects - all effects go through Cmd/Sub in Elm
- Embrace immutability - Elm has no mutable state
- Adopt The Elm Architecture - don't write "Python code in Elm syntax"
- No runtime exceptions - compiler catches all errors
- JSON handling is explicit - write decoders/encoders for all data
- Test equivalence - same inputs → same outputs (for pure functions)
Critical Paradigm Shift: Python → Elm
What Makes This Conversion Different
Python to Elm is not just a syntax translation - it represents a fundamental shift in programming paradigm:
| Aspect | Python | Elm |
|---|---|---|
| Type System | Dynamic, runtime | Static, compile-time |
| Null Safety | None everywhere, NoneType errors | Maybe type, no null |
| Error Handling | Exceptions | Result/Maybe, no exceptions |
| Mutability | Mutable by default | Immutable always |
| Side Effects | Anywhere | Only through Cmd/Sub at edges |
| Concurrency | async/await, threading | N/A - single-threaded with Cmd/Sub |
| OOP | Classes, inheritance | N/A - data and functions separate |
| Runtime | CPython, PyPy (backend) | JavaScript (browser) |
| Package Manager | pip, poetry | elm.json |
| REPL | Interactive Python shell | elm repl (limited) |
The Elm Philosophy
"If it compiles, it works" - Elm's compiler is so strict that runtime exceptions are virtually impossible:
- No null pointer exceptions - Maybe type makes absence explicit
- No undefined is not a function - All types known at compile time
- No runtime type errors - Static types prevent mismatches
- No silent failures - Result type makes errors explicit
Making Impossible States Impossible - Elm encourages modeling your domain such that invalid states cannot be represented:
# Python: Invalid states possible
class User:
def __init__(self):
self.loading = False
self.data = None
self.error = None
# Problem: Can have loading=True, data=X, error=Y simultaneously!
-- Elm: Invalid states impossible
type UserState
= Loading
| Success User
| Failure Http.Error
-- Can only be in ONE state at a time
The 10 Pillars of Conversion
This skill organizes Python → Elm conversion patterns into 10 pillars based on the meta-convert-dev framework:
- Module System - Python packages → Elm modules
- Error Handling - Exceptions → Result/Maybe
- Concurrency - async/await → Cmd/Sub (Elm Architecture)
- Metaprogramming - Decorators/metaclasses → elm-codegen
- Zero/Default Values - None/defaults → Maybe/withDefault
- Serialization - Pydantic → Json.Decode/Json.Encode
- Build & Dependencies - pip/poetry → elm.json
- Testing - pytest → elm-test
- Dev Workflow & REPL - Python REPL → elm repl/reactor
- FFI/Interop - C extensions → Ports (JavaScript interop)
Each pillar is covered in detail below.
Pillar 1: Module System Translation
Python's flexible module system with dynamic imports contrasts with Elm's strict, static module system.
Module Declaration
Python:
# mymodule.py - filename determines module name
# No explicit declaration needed
def public_function():
pass
def _private_function():
pass
__all__ = ['public_function'] # Optional export control
Elm:
-- MyModule.elm - filename must match module name
module MyModule exposing (publicFunction)
-- Must explicitly declare what's exposed
publicFunction : String -> String
publicFunction input =
privateFunction input
-- Not exposed, module-private
privateFunction : String -> String
privateFunction input =
String.toUpper input
Import Patterns
| Python | Elm | Notes |
|---|---|---|
import json |
import Json.Decode |
Qualified import |
import json as j |
import Json.Decode as Decode |
Alias |
from json import loads |
import Json.Decode exposing (decodeString) |
Specific items |
from json import * |
import Json.Decode exposing (..) |
All items (discouraged) |
import .relative |
- | No relative imports in Elm |
Package Structure
Python:
myproject/
myproject/
__init__.py # Package marker
core.py
utils/
__init__.py
helpers.py
Elm:
myproject/
src/
Main.elm # Entry point
Core.elm # No __init__ needed
Utils/
Helpers.elm # Capitalized names
Avoiding Import Cycles
Python:
# a.py
from b import B
class A:
def use_b(self, b: B):
pass
# b.py
from a import A # Circular import!
class B:
def use_a(self, a: A):
pass
# Solution: Move to separate types.py or use TYPE_CHECKING
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from a import A
Elm:
-- Elm PROHIBITS circular imports at compile time
-- Solution: Extract shared types
-- Types.elm
module Types exposing (User, Msg(..))
type alias User = { name : String }
type Msg = UpdateUser User
-- ModuleA.elm
import Types exposing (User, Msg)
-- ModuleB.elm
import Types exposing (User, Msg)
Opaque Types (Encapsulation)
Python:
# email.py
class Email:
def __init__(self, value: str):
if "@" not in value:
raise ValueError("Invalid email")
self._value = value # Convention: _ = private
@property
def value(self) -> str:
return self._value
# Users can still access _value directly (no true privacy)
Elm:
-- Email.elm
module Email exposing (Email, fromString, toString)
-- Opaque type: constructor NOT exposed
type Email = Email String
fromString : String -> Maybe Email
fromString str =
if String.contains "@" str then
Just (Email str)
else
Nothing
toString : Email -> String
toString (Email str) =
str
-- Users CANNOT construct Email directly
-- Email "invalid" → Compile error!
Pillar 2: Error Handling Translation
Python's exception-based error handling fundamentally differs from Elm's type-based error handling.
Exception Model → Result/Maybe Types
Python exceptions:
- Thrown anywhere
- Propagate up the stack
- Can crash if not caught
- Types not tracked
Elm Result/Maybe:
- Explicit in type signature
- Must be handled explicitly
- Cannot crash (compiler enforces handling)
- Types tracked at compile time
Try/Except → Pattern Matching
Python:
def parse_user(json_str: str) -> User:
try:
data = json.loads(json_str)
return User(
name=data['name'],
email=data['email']
)
except (json.JSONDecodeError, KeyError) as e:
raise ValueError(f"Failed to parse user: {e}")
except Exception as e:
# Unexpected error
raise
Elm:
-- All errors are values, not exceptions
parseUser : String -> Result String User
parseUser jsonStr =
Decode.decodeString userDecoder jsonStr
|> Result.mapError (\err -> "Failed to parse user: " ++ Decode.errorToString err)
-- Type signature SHOWS this can fail
-- Compiler FORCES caller to handle Result
None/AttributeError → Maybe
Python:
def get_user_email(user_id: int) -> str | None:
user = database.get(user_id) # Returns None if not found
if user is None:
return None
return user.email
# Usage:
email = get_user_email(123)
if email is not None:
send_email(email)
else:
print("User not found")
Elm:
getUserEmail : Int -> Maybe String
getUserEmail userId =
Dict.get userId database
|> Maybe.map .email
-- Usage: Explicit handling required
case getUserEmail 123 of
Just email ->
sendEmail email
Nothing ->
"User not found"
-- Or use Maybe combinators
getUserEmail 123
|> Maybe.withDefault "no-reply@example.com"
|> sendEmail
Result Combinators vs Try/Except Chains
Python:
def process_order(order_id: int) -> Order:
try:
raw_order = fetch_order(order_id)
except HTTPError as e:
raise OrderError(f"Failed to fetch: {e}")
try:
validated = validate_order(raw_order)
except ValidationError as e:
raise OrderError(f"Validation failed: {e}")
try:
saved = save_order(validated)
except DatabaseError as e:
raise OrderError(f"Save failed: {e}")
return saved
Elm:
-- Railway-Oriented Programming
processOrder : Int -> Task Error Order
processOrder orderId =
fetchOrder orderId
|> Task.andThen validateOrder
|> Task.andThen saveOrder
-- All errors flow through the Result/Task
-- No hidden exceptions
-- Type signature shows Error possibility
Error Types Translation
Python:
class AppError(Exception):
pass
class NotFoundError(AppError):
def __init__(self, resource: str):
super().__init__(f"{resource} not found")
class ValidationError(AppError):
def __init__(self, field: str, message: str):
super().__init__(f"{field}: {message}")
# Usage:
raise NotFoundError("User")
Elm:
-- Use discriminated unions for errors
type AppError
= NotFound String
| ValidationError String String
| NetworkError Http.Error
-- Usage: Return error as value
findUser : Int -> Result AppError User
findUser id =
case Dict.get id users of
Just user ->
Ok user
Nothing ->
Err (NotFound "User")
-- Pattern match to handle
case findUser 123 of
Ok user ->
viewUser user
Err (NotFound resource) ->
text ("Not found: " ++ resource)
Err (ValidationError field msg) ->
text (field ++ ": " ++ msg)
Err (NetworkError httpErr) ->
text "Network error"
Validation Patterns
Python (with Pydantic):
from pydantic import BaseModel, validator, ValidationError
class User(BaseModel):
name: str
email: str
age: int
@validator('email')
def validate_email(cls, v):
if '@' not in v:
raise ValueError('Invalid email')
return v
@validator('age')
def validate_age(cls, v):
if v < 0:
raise ValueError('Age must be positive')
return v
# Usage:
try:
user = User(name="Alice", email="alice@example.com", age=30)
except ValidationError as e:
print(e.errors())
Elm:
-- Validation returns Result
type alias User =
{ name : String
, email : String
, age : Int
}
type ValidationError
= InvalidEmail
| InvalidAge
validateEmail : String -> Result ValidationError String
validateEmail email =
if String.contains "@" email then
Ok email
else
Err InvalidEmail
validateAge : Int -> Result ValidationError Int
validateAge age =
if age >= 0 then
Ok age
else
Err InvalidAge
createUser : String -> String -> Int -> Result ValidationError User
createUser name email age =
Result.map3 User
(Ok name)
(validateEmail email)
(validateAge age)
-- Usage: Must handle Result
case createUser "Alice" "alice@example.com" 30 of
Ok user ->
-- Success
viewUser user
Err InvalidEmail ->
text "Invalid email address"
Err InvalidAge ->
text "Age must be positive"
Pillar 3: Concurrency Translation
Critical Note: Elm has NO concurrency model in the traditional sense. Elm is single-threaded and runs in the browser's event loop. All "async" operations are managed through The Elm Architecture via Cmd and Sub.
Python Async/Threading → Elm Architecture
Python async/await:
import asyncio
import aiohttp
async def fetch_users():
async with aiohttp.ClientSession() as session:
async with session.get('https://api.example.com/users') as response:
return await response.json()
async def main():
users = await fetch_users()
print(f"Fetched {len(users)} users")
asyncio.run(main())
Elm with Cmd (no async/await):
-- ALL effects happen at the edges via Cmd
type Msg
= FetchUsers
| GotUsers (Result Http.Error (List User))
type alias Model =
{ users : List User
, status : String
}
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
FetchUsers ->
( { model | status = "Loading..." }
, Http.get
{ url = "https://api.example.com/users"
, expect = Http.expectJson GotUsers usersDecoder
}
)
GotUsers (Ok users) ->
( { model | users = users, status = "Success" }
, Cmd.none
)
GotUsers (Err error) ->
( { model | status = "Failed to fetch users" }
, Cmd.none
)
-- No "await" - results come back as Msg
Threading → No Threading
Python threading:
import threading
import queue
def worker(q):
while True:
item = q.get()
if item is None:
break
process(item)
q.task_done()
q = queue.Queue()
threads = []
for i in range(4):
t = threading.Thread(target=worker, args=(q,))
t.start()
threads.append(t)
# Add work
for item in items:
q.put(item)
# Wait for completion
q.join()
Elm (no threading):
-- Elm is single-threaded
-- All operations happen in sequence in the event loop
-- For "parallel" HTTP requests, use batch:
type Msg
= FetchAll
| GotUser1 (Result Http.Error User)
| GotUser2 (Result Http.Error User)
| GotUser3 (Result Http.Error User)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
FetchAll ->
( model
, Cmd.batch
[ Http.get { url = "/user/1", expect = Http.expectJson GotUser1 userDecoder }
, Http.get { url = "/user/2", expect = Http.expectJson GotUser2 userDecoder }
, Http.get { url = "/user/3", expect = Http.expectJson GotUser3 userDecoder }
]
)
-- Handle each response separately
GotUser1 result ->
-- ...
GotUser2 result ->
-- ...
GotUser3 result ->
-- ...
-- Requests happen "in parallel" (browser manages concurrency)
-- But results are processed sequentially in update function
Background Tasks → Subscriptions
Python (background polling):
import asyncio
async def poll_status():
while True:
status = await fetch_status()
print(f"Status: {status}")
await asyncio.sleep(5)
asyncio.create_task(poll_status())
Elm (subscriptions):
-- Subscriptions provide ongoing effects
import Time
type Msg
= Tick Time.Posix
| GotStatus (Result Http.Error Status)
subscriptions : Model -> Sub Msg
subscriptions model =
Time.every 5000 Tick -- Every 5 seconds
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Tick time ->
( model
, Http.get
{ url = "/status"
, expect = Http.expectJson GotStatus statusDecoder
}
)
GotStatus (Ok status) ->
( { model | status = status }
, Cmd.none
)
GotStatus (Err _) ->
( model, Cmd.none )
WebSockets
Python (websockets library):
import asyncio
import websockets
async def listen():
async with websockets.connect('ws://localhost:8000') as ws:
async for message in ws:
print(f"Received: {message}")
asyncio.run(listen())
Elm (ports for WebSockets):
-- WebSockets via ports (JavaScript interop)
port module Main exposing (..)
-- Outgoing: Send to JavaScript
port sendMessage : String -> Cmd msg
-- Incoming: Receive from JavaScript
port receiveMessage : (String -> msg) -> Sub msg
type Msg
= Send String
| Receive String
subscriptions : Model -> Sub Msg
subscriptions model =
receiveMessage Receive
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Send message ->
( model, sendMessage message )
Receive message ->
( { model | messages = message :: model.messages }
, Cmd.none
)
// JavaScript side (ports)
const app = Elm.Main.init({ node: document.getElementById('elm') });
const ws = new WebSocket('ws://localhost:8000');
ws.onmessage = (event) => {
app.ports.receiveMessage.send(event.data);
};
app.ports.sendMessage.subscribe((message) => {
ws.send(message);
});
Concurrency Checklist
When converting Python concurrency to Elm:
- Identify all async operations (HTTP, timers, WebSockets)
- Model each async result as a Msg variant
- Use
Cmdfor one-off effects (HTTP requests) - Use
Subfor ongoing effects (timers, WebSocket messages) - Use
Cmd.batchfor "parallel" operations - Use ports for complex async (WebSockets, file I/O)
- Remove all threading code (Elm is single-threaded)
- Remove all mutexes/locks (Elm is immutable)
Pillar 4: Metaprogramming Translation
Critical Note: Elm has NO runtime metaprogramming. No decorators, no metaclasses, no eval(), no dynamic code generation at runtime.
Python Metaprogramming → Elm Alternatives
| Python Metaprogramming | Elm Alternative | Notes |
|---|---|---|
| Decorators | Higher-order functions | Wrap functions at compile time |
| Metaclasses | elm-codegen | Generate code before compilation |
@property |
Record fields | Direct field access |
__getattr__ |
N/A | No dynamic attribute access |
type() / isinstance() |
Discriminated unions | Pattern matching |
eval() / exec() |
N/A | No runtime code evaluation |
| Descriptor protocol | N/A | No dynamic behavior |
| Context managers | N/A | Use explicit cleanup in ports |
Decorators → Higher-Order Functions
Python decorators:
import time
from functools import wraps
def timing(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.2f}s")
return result
return wrapper
@timing
def slow_function(n):
time.sleep(n)
return n * 2
result = slow_function(2) # Prints timing info
Elm (higher-order functions):
-- Elm has no side effects in functions, so timing must go through Cmd
-- But the pattern of wrapping functions works:
type alias Logger msg =
{ log : String -> Cmd msg
}
withLogging : Logger msg -> (a -> b) -> a -> ( b, Cmd msg )
withLogging logger func input =
let
result =
func input
logMsg =
"Function executed with input: " ++ Debug.toString input
in
( result, logger.log logMsg )
-- Usage:
slowFunction : Int -> Int
slowFunction n =
n * 2
-- In update function:
let
( result, cmd ) =
withLogging logger slowFunction 2
in
( { model | result = result }, cmd )
Code Generation: Python → elm-codegen
Python (dynamic code generation):
# Generate classes dynamically
def make_model(fields):
class DynamicModel:
def __init__(self, **kwargs):
for field in fields:
setattr(self, field, kwargs.get(field))
return DynamicModel
User = make_model(['name', 'email', 'age'])
user = User(name="Alice", email="alice@example.com", age=30)
Elm (elm-codegen):
-- Elm cannot generate code at runtime
-- Use elm-codegen to generate Elm code before compilation
-- codegen/Generate.elm (runs before compilation)
module Generate exposing (main)
import Elm
import Elm.Annotation as Type
main : Program {} () ()
main =
Elm.generate "src/Generated/Models.elm"
[ Elm.file [ "Generated", "Models" ]
[ Elm.alias "User"
(Type.record
[ ( "name", Type.string )
, ( "email", Type.string )
, ( "age", Type.int )
]
)
]
]
-- Run: elm-codegen run
-- Generates src/Generated/Models.elm with type alias User
Property Accessors → Record Fields
Python:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def area(self):
return 3.14159 * self._radius ** 2
@property
def circumference(self):
return 2 * 3.14159 * self._radius
circle = Circle(5)
print(circle.area) # Computed property
Elm:
-- No computed properties
-- Use functions instead
type alias Circle =
{ radius : Float
}
area : Circle -> Float
area circle =
pi * circle.radius ^ 2
circumference : Circle -> Float
circumference circle =
2 * pi * circle.radius
-- Usage:
circle = { radius = 5 }
area circle |> Debug.toString
Type Introspection → Pattern Matching
Python:
from typing import Union
def process(value: Union[int, str, list]):
if isinstance(value, int):
return value * 2
elif isinstance(value, str):
return value.upper()
elif isinstance(value, list):
return len(value)
else:
raise TypeError(f"Unexpected type: {type(value)}")
Elm:
-- Use discriminated unions + pattern matching
type Value
= IntValue Int
| StringValue String
| ListValue (List a)
process : Value -> Int
process value =
case value of
IntValue n ->
n * 2
StringValue str ->
String.length str -- Can't return different types
ListValue list ->
List.length list
-- Note: All branches must return same type
-- This is a FEATURE - prevents type confusion
Metaprogramming Checklist
When converting Python metaprogramming to Elm:
- Replace decorators with higher-order functions
- Move code generation to elm-codegen (pre-compilation)
- Replace
@propertywith explicit functions - Replace
isinstance()checks with discriminated unions - Remove all
eval()/exec()code (no equivalent) - Document which metaprogramming patterns have no Elm equivalent
- Consider if JavaScript interop (ports) is needed
Pillar 5: Zero and Default Values Translation
Python and Elm have fundamentally different approaches to "absence of value."
None → Maybe
Python:
from typing import Optional
def find_user(user_id: int) -> Optional[dict]:
if user_id in users:
return users[user_id]
else:
return None
# Usage:
user = find_user(123)
if user is not None:
print(user['name'])
else:
print("Not found")
# Or with walrus operator:
if (user := find_user(123)) is not None:
print(user['name'])
Elm:
findUser : Int -> Maybe User
findUser userId =
Dict.get userId users
-- Usage: Must handle Maybe explicitly
case findUser 123 of
Just user ->
text user.name
Nothing ->
text "Not found"
-- Or with Maybe.withDefault:
findUser 123
|> Maybe.map .name
|> Maybe.withDefault "Not found"
|> text
Default Arguments → Record Update
Python:
def create_user(name: str, email: str = "no-reply@example.com", age: int = 0):
return {
'name': name,
'email': email,
'age': age
}
user1 = create_user("Alice")
user2 = create_user("Bob", email="bob@example.com")
user3 = create_user("Charlie", age=30)
Elm:
-- No default arguments in Elm
-- Pattern 1: Multiple constructor functions
type alias User =
{ name : String
, email : String
, age : Int
}
createUser : String -> String -> Int -> User
createUser name email age =
{ name = name, email = email, age = age }
createUserWithDefaults : String -> User
createUserWithDefaults name =
{ name = name
, email = "no-reply@example.com"
, age = 0
}
-- Pattern 2: Builder pattern with record update
defaultUser : User
defaultUser =
{ name = ""
, email = "no-reply@example.com"
, age = 0
}
user1 = { defaultUser | name = "Alice" }
user2 = { defaultUser | name = "Bob", email = "bob@example.com" }
user3 = { defaultUser | name = "Charlie", age = 30 }
-- Pattern 3: Config record
type alias UserConfig =
{ name : String
, email : Maybe String
, age : Maybe Int
}
createUserFromConfig : UserConfig -> User
createUserFromConfig config =
{ name = config.name
, email = Maybe.withDefault "no-reply@example.com" config.email
, age = Maybe.withDefault 0 config.age
}
user1 = createUserFromConfig { name = "Alice", email = Nothing, age = Nothing }
user2 = createUserFromConfig { name = "Bob", email = Just "bob@example.com", age = Nothing }
Mutable Default Arguments (Gotcha!)
Python (dangerous pattern):
def append_to(element, target=[]): # DANGEROUS!
target.append(element)
return target
# Gotcha: Default list is shared!
list1 = append_to(1) # [1]
list2 = append_to(2) # [1, 2] ← Shared state!
# Correct pattern:
def append_to(element, target=None):
if target is None:
target = []
target.append(element)
return target
Elm (impossible to have this bug):
-- Elm has no mutable defaults or mutable anything
appendTo : a -> List a -> List a
appendTo element target =
target ++ [ element ]
-- Always creates new list
list1 = appendTo 1 [] -- [1]
list2 = appendTo 2 [] -- [2]
list3 = appendTo 3 list1 -- [1, 3]
-- list1 is still [1] - immutable!
Dictionary Defaults
Python:
from collections import defaultdict
# Pattern 1: defaultdict
counts = defaultdict(int)
counts['apples'] += 1 # 0 + 1 = 1
# Pattern 2: dict.get with default
counts = {}
counts['apples'] = counts.get('apples', 0) + 1
# Pattern 3: dict.setdefault
counts = {}
counts.setdefault('apples', 0)
counts['apples'] += 1
Elm:
-- Dict.update pattern (functional)
counts : Dict String Int
counts =
Dict.empty
incrementCount : String -> Dict String Int -> Dict String Int
incrementCount key dict =
Dict.update key
(\maybeValue ->
case maybeValue of
Just count ->
Just (count + 1)
Nothing ->
Just 1
)
dict
-- Usage:
newCounts = incrementCount "apples" counts
-- Or helper function:
incrementDefault : comparable -> Int -> Dict comparable Int -> Dict comparable Int
incrementDefault key default dict =
Dict.insert key
(Dict.get key dict |> Maybe.withDefault default |> (+) 1)
dict
Falsy Values
Python (truthy/falsy):
# Many values are "falsy" in Python
if not value: # Could be: None, False, 0, "", [], {}, etc.
print("Falsy!")
# Explicit checks often better:
if value is None:
print("Actually None")
if value == "":
print("Empty string")
if len(value) == 0:
print("Empty collection")
Elm (no truthiness):
-- ONLY Bool values can be used in conditions
-- No truthiness/falsiness concept
-- Check for Maybe:
case maybeValue of
Just value -> "Has value"
Nothing -> "No value"
-- Check for empty:
if String.isEmpty str then
"Empty string"
else
"Has content"
if List.isEmpty list then
"Empty list"
else
"Has items"
-- No implicit boolean conversion
-- This is a COMPILE ERROR:
-- if someString then ... ← ERROR: String is not Bool
Pillar 6: Serialization Translation
Python's Pydantic and JSON handling is runtime-based; Elm's JSON decoders/encoders are compile-time safe.
Pydantic → Json.Decode
Python (Pydantic):
from pydantic import BaseModel
from typing import Optional
class Address(BaseModel):
street: str
city: str
zip_code: Optional[str] = None
class User(BaseModel):
id: int
name: str
email: str
address: Optional[Address] = None
# Automatic parsing:
json_str = '{"id": 1, "name": "Alice", "email": "alice@example.com"}'
user = User.parse_raw(json_str) # Automatic!
print(user.name) # Alice
Elm (Json.Decode):
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (required, optional)
type alias Address =
{ street : String
, city : String
, zipCode : Maybe String
}
type alias User =
{ id : Int
, name : String
, email : String
, address : Maybe Address
}
-- Must write explicit decoder
addressDecoder : Decoder Address
addressDecoder =
Decode.succeed Address
|> required "street" Decode.string
|> required "city" Decode.string
|> optional "zip_code" (Decode.nullable Decode.string) Nothing
userDecoder : Decoder User
userDecoder =
Decode.succeed User
|> required "id" Decode.int
|> required "name" Decode.string
|> required "email" Decode.string
|> optional "address" (Decode.nullable addressDecoder) Nothing
-- Usage:
jsonStr = """{"id": 1, "name": "Alice", "email": "alice@example.com"}"""
case Decode.decodeString userDecoder jsonStr of
Ok user ->
text user.name
Err error ->
text ("Decode failed: " ++ Decode.errorToString error)
JSON Encoding
Python:
import json
from dataclasses import dataclass, asdict
@dataclass
class User:
id: int
name: str
email: str
user = User(id=1, name="Alice", email="alice@example.com")
json_str = json.dumps(asdict(user))
# {"id": 1, "name": "Alice", "email": "alice@example.com"}
Elm:
import Json.Encode as Encode
type alias User =
{ id : Int
, name : String
, email : String
}
encodeUser : User -> Encode.Value
encodeUser user =
Encode.object
[ ( "id", Encode.int user.id )
, ( "name", Encode.string user.name )
, ( "email", Encode.string user.email )
]
-- Usage:
user = { id = 1, name = "Alice", email = "alice@example.com" }
jsonStr = Encode.encode 0 (encodeUser user)
-- {"id":1,"name":"Alice","email":"alice@example.com"}
Nested JSON
Python:
from pydantic import BaseModel
from typing import List
class Comment(BaseModel):
author: str
text: str
class Post(BaseModel):
title: str
comments: List[Comment]
json_data = {
"title": "Hello",
"comments": [
{"author": "Alice", "text": "Great!"},
{"author": "Bob", "text": "Thanks!"}
]
}
post = Post(**json_data)
print(post.comments[0].author) # Alice
Elm:
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (required)
type alias Comment =
{ author : String
, text : String
}
type alias Post =
{ title : String
, comments : List Comment
}
commentDecoder : Decoder Comment
commentDecoder =
Decode.succeed Comment
|> required "author" Decode.string
|> required "text" Decode.string
postDecoder : Decoder Post
postDecoder =
Decode.succeed Post
|> required "title" Decode.string
|> required "comments" (Decode.list commentDecoder)
-- Usage:
jsonStr = """
{
"title": "Hello",
"comments": [
{"author": "Alice", "text": "Great!"},
{"author": "Bob", "text": "Thanks!"}
]
}
"""
case Decode.decodeString postDecoder jsonStr of
Ok post ->
case List.head post.comments of
Just firstComment ->
text firstComment.author
Nothing ->
text "No comments"
Err error ->
text ("Decode failed: " ++ Decode.errorToString error)
Handling API Variants
Python:
from typing import Union, Literal
from pydantic import BaseModel
class SuccessResponse(BaseModel):
status: Literal["success"]
data: dict
class ErrorResponse(BaseModel):
status: Literal["error"]
message: str
Response = Union[SuccessResponse, ErrorResponse]
def handle_response(response: Response):
if isinstance(response, SuccessResponse):
print(response.data)
elif isinstance(response, ErrorResponse):
print(f"Error: {response.message}")
Elm:
-- Use discriminated union + oneOf decoder
type ApiResponse
= Success (Dict String String)
| Error String
apiResponseDecoder : Decoder ApiResponse
apiResponseDecoder =
Decode.field "status" Decode.string
|> Decode.andThen
(\status ->
case status of
"success" ->
Decode.map Success
(Decode.field "data" (Decode.dict Decode.string))
"error" ->
Decode.map Error
(Decode.field "message" Decode.string)
_ ->
Decode.fail ("Unknown status: " ++ status)
)
-- Usage:
handleResponse : ApiResponse -> String
handleResponse response =
case response of
Success data ->
"Got data: " ++ Debug.toString data
Error message ->
"Error: " ++ message
Serialization Checklist
When converting Python serialization to Elm:
- Replace Pydantic models with Elm type aliases + decoders
- Write explicit decoder for each type
- Write explicit encoder for each type (if sending JSON)
- Use
Json.Decode.Pipelinefor complex decoders - Handle optional fields with
optionalfrom pipeline - Use
andThenfor conditional decoding (union types) - Test decoders with sample JSON data
- Document field name mappings (snake_case → camelCase)
Pillar 7: Build & Dependencies Translation
Package Management
| Python | Elm | Notes |
|---|---|---|
requirements.txt |
elm.json |
Dependency manifest |
pyproject.toml |
elm.json |
Modern Python ≈ Elm manifest |
pip install |
elm install |
Install dependency |
pip install -e . |
N/A | No editable installs |
venv / virtualenv |
N/A | Elm dependencies are per-project |
poetry / pipenv |
N/A | elm.json is the only tool |
elm.json Structure
Python (pyproject.toml):
[project]
name = "myapp"
version = "0.1.0"
dependencies = [
"requests>=2.28.0",
"pydantic>=2.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"black>=23.0.0",
]
Elm (elm.json):
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.3"
},
"indirect": {
"elm/time": "1.0.0",
"elm/url": "1.0.0"
}
},
"test-dependencies": {
"direct": {
"elm-explorations/test": "1.2.2"
},
"indirect": {}
}
}
Common Dependency Translations
| Python Package | Elm Package | Purpose |
|---|---|---|
requests |
elm/http |
HTTP requests |
pydantic |
elm/json + custom decoders |
JSON validation |
pytest |
elm-explorations/test |
Testing |
typing |
Built-in type system | Type annotations |
dataclasses |
Built-in records | Data structures |
enum |
Built-in union types | Enumerations |
datetime |
elm/time |
Date/time handling |
re |
elm/regex |
Regular expressions |
asyncio |
elm/core (Cmd/Sub) |
Async operations |
Adding Dependencies
Python:
# Add to requirements.txt:
requests>=2.28.0
pydantic>=2.0.0
# Or with poetry:
poetry add requests pydantic
Elm:
# Elm installs and updates elm.json automatically:
elm install elm/http
elm install elm/json
# Always uses exact versions (no semver ranges in direct deps)
Build Process
Python:
# No build step (interpreted)
python main.py
# Or package:
python -m build
pip install dist/myapp-0.1.0-py3-none-any.whl
Elm:
# Compile to JavaScript:
elm make src/Main.elm --output=main.js
# Compile optimized:
elm make src/Main.elm --optimize --output=main.js
# Development server:
elm reactor # Opens http://localhost:8000
Project Structure
Python:
myproject/
myproject/
__init__.py
main.py
models.py
api.py
tests/
__init__.py
test_main.py
pyproject.toml
README.md
Elm:
myproject/
src/
Main.elm
Types.elm
Api.elm
tests/
MainTest.elm
elm.json
README.md
Pillar 8: Testing Translation
pytest → elm-test
Python (pytest):
import pytest
from myapp import add, divide
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
def test_divide():
assert divide(10, 2) == 5
with pytest.raises(ZeroDivisionError):
divide(10, 0)
@pytest.mark.parametrize("a,b,expected", [
(
…(truncated)