A batch job, in The Elm Architecture · cekrem.github.ioAt the end of my native Elm post I said I hoped my next elm-run project would be a REST API, or maybe replacing some small existing app. I lied a little. I went the other direction and grabbed something much bigger: a real batch job, inside a real (and fairly large) application I’m actually building. The fetch PoC was 80 lines of the most ordinary Elm imaginable. This one isn’t a toy, and that’s the whole point – Damir (who’s building elm-run) needs someone leaning on it at scale and reporting back what creaks, so that’s what I’ve been doing.<br>Quick confession before anything else: This batch works, it’s correct, the types are lovely, but “production-grade throughput” it is not (but closer than one might think!). I’ll get there (or elm-run will, rather). What I want to talk about isn’t the speed. It’s the shape.<br>I’ve done the CLI thing before
Link to heading<br>This isn’t my first batch-ish rodeo in Elm. Back in February I wrote blog-bot, a little Bluesky poster that runs daily on GitHub Actions, using elm-pages Script mode. That was genuinely great. BackendTask chained five steps together, the compiler caught my mistakes, and I deployed the whole thing in a handful of commits.<br>But elm-pages Script is a pipeline shape. You compose tasks, you andThen your way to the end, Node runs underneath. Perfect for “read RSS, transform, post, done.” What it is not is The Elm Architecture. There’s no Model, no update, no long-lived loop with effects flowing in and out over time. For a fire-and-forget pipeline you don’t miss that. For a job that has to babysit a few hundred things, each one drifting through several states at its own pace, some of them failing halfway and needing a nudge – you miss it a lot.<br>That’s the difference native Elm gave me. Not “Elm can run a script” (elm-pages already does that, and quite beautifully at that). It’s “Elm can run the loop – init, update, subscriptions – against the OS instead of the browser.” Same loop I’ve written a hundred times for widgets. Now it’s doing back-office work.<br>Each item is a tiny state machine
Link to heading<br>A batch over a list of things is really a list of little lifecycles, and a lifecycle is exactly what a union type is for. So instead of a record with a status : String and a pile of nullable fields (the shape every untyped worker eventually rots into), each item is this:<br>type Item<br>= Queued Input<br>| Submitted Ref<br>| Processing Ref Partial<br>| Finished Ref Result<br>| Failed Item Http.Error
That’s not like railway-oriented programming. It is railway-oriented programming, just with more stations on the track. Scott Wlaschin’s Result – the two-state success/failure railway everyone draws – is the degenerate case of this. Add intermediate stops and you’ve got the same track, longer. (I’ve written about parse, don’t validate before; this is the same instinct pointed at a process instead of a value.)<br>And the transitions are functions whose types won’t let you cheat. The step that turns a Submitted into a Processing simply cannot be handed a Queued. You can’t fetch a result for something you never submitted, because the function that fetches results asks for a Ref, and a Queued item hasn’t got one yet. I didn’t write a single guard for any of that. It’s a product of the shape. The illegal orderings became unrepresentable, not just the illegal values.<br>A failure that remembers where it was standing
Link to heading<br>Look at that last branch again:<br>| Failed Item Http.Error
Failed carries the whole previous Item. Not an error code floating in the void – the actual last-good state the item was in when the HTTP call gave up. Which means rewinding a failure is comically boring:<br>rewind : Item -> Item<br>rewind item =<br>case item of<br>Failed previous _ -><br>previous
_ -><br>item
A thrown exception is amnesiac. It knows that it failed, never where you were standing when it did. So you restart from the depot and re-run the three expensive calls that already succeeded. Here the failure is a checkpoint with a return address. Retry isn’t “start over and pray it’s idempotent” – it’s “resume from the siding you parked on.” The type literally hands you back the state from just before things went sideways.<br>In a request/response handler you can almost get away without this. One request dies, the user hits refresh, nobody notices. In a batch over a few hundred items, item 287 will fail, and “the job crashed at 64%” is a disaster while “283 finished, 4 parked, here they are” is a Tuesday. Batch is precisely where the difference between an exception and a checkpoint stops being academic.<br>Kicking the whole list off is the same TEA reflex too – map your inputs into pending...