Blog › ICP guides
Roc developer on retainer: Task effects, backpassing, platform split, tag unions, and Roc functional programming on monthly retainer
September 26, 2026 · ~15 min read
A Roc application needed to fetch a user record from the platform’s HTTP layer and handle both the success and error cases. The developer wrote Task.attempt (getUser id) \err -> handleError err expecting this to call Task.attempt with the task as the first argument and a partial error-handler lambda as the second. In Roc, a backslash lambda without enclosing parentheses is not a partial application — it is a standalone function literal with the lowest possible precedence. The expression Task.attempt (getUser id) \err -> handleError err is parsed as applying Task.attempt to two positional arguments: the task (getUser id) as the first argument, and the lambda \err -> handleError err as the second argument. This appears correct, but the lambda’s type does not match Task.attempt’s expected second-argument type: Task.attempt expects a function from Result ok err to Task b err2 — a result handler that receives both Ok and Err variants — but the lambda only handles one variable err without a when ... is pattern match on the Result. Type errors: 2 per call. The developer restructured to Task.attempt (getUser id) (\result -> when result is Ok user -> processUser user; Err err -> handleError err). Type errors: 2/call → 0. The Roc developer on retainer diagnosed the type mismatch between the lambda shape and the Task.attempt signature: Task.attempt passes a Result to the callback, not just the error value, and the callback must exhaustively match both Ok and Err variants.
The work log entry read “fixed async error handling, 8h.” It names the result and duration. It cannot explain why the lambda shape caused the type error — Roc’s Task.attempt signature is Task.attempt : Task a err, (Result a err -> Task b err2) -> Task b err2; the callback receives the full Result a err so it can handle both Ok a and Err err variants with a single function; the common mistake is writing a callback that handles only the error (modeling it as a catch-style handler from exception-based languages) when Roc’s functional model requires handling both outcomes in the same callback; the callback that handles only err is a type error because it does not handle the Ok branch. It cannot explain Roc’s backpassing syntax that eliminates this entire class of callback nesting — user <- Task.await (getUser id) desugars to Task.await (getUser id) (\user -> ...rest of block...); the <- operator pulls the success value out of the task and binds it as a name in the rest of the expression, flattening sequential task compositions without explicit callbacks; errors propagate automatically through the backpassing chain using the task’s error type; explicit error handling is done at the end of the chain with Task.attempt or Task.mapErr. It cannot explain Roc’s tag union exhaustiveness requirements — every when expression must cover all variants of the tag union being matched; adding a new tag variant to a union forces updates at every match site in the program; the Roc compiler reports each unhandled variant as a type error; this exhaustiveness guarantee is the mechanism that makes tag unions safe across code evolution. The 8 hours of Task combinator analysis, backpassing conversion, and tag union exhaustiveness audit are invisible in the diff.
Roc types: tag unions, records, Result, Task, and the closed/open union distinction
Roc’s type system is built around tag unions (sum types) and records (product types) with full type inference. A tag union in Roc is written as [TagA type1, TagB type2, ...]. A closed tag union with explicit variants requires exhaustive matching in every when expression. An open tag union using a type variable extension ([TagA type1, TagB type2]* or [TagA type1, TagB type2]a) permits additional tags at the use site, enabling polymorphic functions that accept any tag union containing at least the specified variants. The distinction between closed and open tag unions is a critical API design decision: closed unions are correct for result types and domain-specific enumerations where exhaustiveness checking at match sites is the safety property; open unions are correct for extensible error types or tag-based polymorphism where callers need to add variants. The Result ok err built-in is a closed tag union [Ok ok, Err err]; every function returning a Result must be matched exhaustively, which is why partial lambda callbacks that only handle err produce type errors.
Roc’s record types are structurally typed: a record { name : Str, age : Num * } is compatible with any record that has at least a name field of type Str and an age field of a numeric type. Field access uses dot syntax: user.name. Record update syntax creates a new record with updated fields: { user & name: "Alice" }. Roc has no mutable state — all values are immutable by default; the update syntax always produces a new record without modifying the original. The Num type is a constrained type variable that covers all numeric types (I32, I64, F32, F64, Dec); Num * accepts any numeric type; Int * and Frac * constrain to integer and fractional subtypes respectively. Roc was designed by Richard Feldman and is in active development as a language for building fast, maintainable applications compiled to native code or WebAssembly. Its retainer work is primarily in teams building CLI tools on the basic-cli platform, web backends on the roc-lang webserver platforms, and embedded applications compiled to WASM. Its closest retainer neighbors are Elm developer retainers (shared ML-family functional syntax) and Haskell developer retainers (shared functional purity context), but Roc’s platform/app split for host-provided effects, backpassing syntax for task sequencing, and tag union exhaustiveness model make the retainer work distinct.
Roc platforms: app/platform split, host effects, Task return types, and glue layer design
Roc’s platform/app split is the mechanism that makes the language simultaneously purely functional and capable of performing I/O. A Roc platform is a host-language module (C, Zig, Rust, or another systems language) that provides effect-capable functions to Roc apps. The platform declares which functions it exposes to Roc programs via the platform header; the Roc app imports those functions and calls them through Task-wrapped interfaces. The platform’s host-side implementation executes the effects and returns values through the platform ABI. The Roc runtime itself contains no I/O primitives — all effects (filesystem, network, stdout, random, time) are provided by the platform. This design means that Roc apps are portable across platforms: the same Roc application code can target the basic-cli platform for native execution, the basic-webserver platform for HTTP request handling, or a custom embedded platform for microcontroller deployment.
The Task ok err type is Roc’s mechanism for sequencing platform-provided effects in a purely functional way. A Task is a description of an effectful computation that, when executed by the platform, produces either an Ok ok value or an Err err value. Task is not a monad in the Haskell sense (Roc has no type classes), but it provides the same sequencing combinators: Task.await sequences two tasks; Task.map transforms the success value; Task.mapErr transforms the error value; Task.attempt runs a task and passes the Result to a callback for branching on success or failure. The backpassing operator <- is syntactic sugar over Task.await that makes sequential task composition read like imperative code without explicit nesting. The platform decides when and how Task values are executed — the Roc runtime submits the Task` description to the platform, the platform performs the effects and returns the result. Retainer work involving platforms typically covers the glue layer between the Roc ABI and the host language, the effect model that the platform exposes (which Task types are available, what error types they use), and debugging mismatches between the Roc app's expected Task` interface and the platform’s actual implementation.
How HourTab tracks Roc developer retainer hours
Roc retainer work carries the invisible-hours problem specific to functional type systems: the program may appear structurally correct — tasks declared, effects called, results handled — until a type mismatch between the lambda shape and the combinator signature produces type errors. The Task.attempt callback mismatch described above is the most common correctness issue in Roc programs written by developers coming from exception-based error handling: they model the task callback as a catch handler (receives only the error) rather than a result handler (receives both Ok and Err variants). Diagnosing this requires understanding Task.attempt’s signature, the closed Result tag union exhaustiveness requirement, and the backpassing syntax that eliminates the need for explicit result-handling callbacks in sequential task chains. A retainer engagement typically involves Task combinator audit (every Task.attempt callback verified for correct Result handling), backpassing conversion (sequential task chains converted from nested callbacks to backpassing syntax), and tag union audit (every when expression verified for exhaustiveness against its tag union type).
HourTab gives Roc developers a public retainer-hours URL they send to clients — typically teams building CLI tools and native applications using the basic-cli platform, web services on Roc webserver platforms, and organizations evaluating Roc for high-performance functional programming without the Haskell complexity ceiling. For Roc retainers, each work log entry should name the mechanism (Task: Task.attempt signature, Task.await sequencing, backpassing conversion; tag union: exhaustive matching, closed vs open variant, variant addition impact; platform: host function import, effect model, glue layer ABI; type inference: field access, record update, Num constraint), the specific combinator call, type mismatch, and before/after type error count, and the design rationale. Roc retainers are often compared to Elm developer retainers for the shared functional purity and beginner-friendly ergonomics context, but Roc’s platform/app split for host-provided effects, backpassing operator for task sequencing, tag union closed/open distinction, and no-typeclass type system make the retainer work distinct in effect model design, task combinator reasoning, and platform interface engineering. HourTab’s work log makes the Task combinator analysis, backpassing conversion, and tag union exhaustiveness audit visible to clients who would otherwise see only the symptom — type errors on task calls — and not understand why the fix required understanding that Task.attempt passes a Result (not just the error) to the callback, and why the backpassing operator <- is the idiomatic way to sequence tasks without callback nesting, and why every when expression matching a Result must cover both Ok and Err variants.
Track Roc developer retainer hours without the status emails
HourTab gives Roc developers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your effects audit log — Task.attempt signature diagnosis, backpassing conversion, tag union exhaustiveness analysis — becomes the proof of value that gets the retainer renewed.
See HourTab pricing →FAQ: Roc developer retainers
What does a Roc developer on retainer typically do?
A Roc developer on monthly retainer covers Roc types (Str, Num, Bool, List, Dict, Set, Result, Task; record syntax with field access; tag unions for sum types; closed tag unions in function signatures; open tag unions with * extension variable), Roc functions (backslash lambda syntax; |> pipe operator; backpassing <- for Task sequencing without callback nesting; no closures over mutable state — purely functional; function composition; partial application via structural lambda), and Roc platforms (platform/app split where platform provides effect-capable host functions; app declares platform dependency; Task wraps host-provided effects; glue layer between Roc and host; no default runtime — platform determines execution model).
What Roc work is most commonly underlogged in a retainer?
Task.attempt argument order diagnosis (developer wrote Task.attempt (getUser id) \err -> handleError err; lambda only handles error variant, not full Result; Task.attempt expects Result a err -> Task b err2 callback covering both Ok and Err; type errors: 2/call → 0 after restructuring to Task.attempt (getUser id) (\result -> when result is Ok user -> ... Err err -> handleError err); 6–10 hrs invisible); backpassing syntax conversion (user <- Task.await someTask desugars to Task.await someTask (\user -> rest); avoids explicit callback nesting for sequential Task composition; 4–8 hrs invisible); tag union exhaustiveness (every when must cover all tag variants; adding a tag to a union forces updates at every match site; closed vs open tag union design for API stability; 5–9 hrs invisible); platform interface design (platform provides host functions; app imports platform functions; Task return type must match platform’s expected effect model; 4–7 hrs invisible).
What are typical Roc developer retainer rates?
Entry-level Roc developers (1–2 years, basic Task usage, tag union matching, Roc standard library functions) bill at $65–$115/hr. Mid-level Roc functional programmers (2–4 years, Task.attempt and backpassing semantics, platform/app split design, tag union closed vs open variants, type inference) bill at $105–$180/hr. Senior Roc platform developers (4–8 years, platform effect system design, host glue layer implementation, high-performance Roc compilation for embedded targets, production functional system architecture) bill at $150–$265/hr. Monthly retainer ranges: $2,500–$5,000/mo advisory (15–25 hrs), $7,000–$17,000/mo for full Roc platform engineering.
What should a Roc developer retainer agreement include?
A Roc developer retainer agreement should specify: Task scope (Task.attempt argument order; Task.await for sequential composition; backpassing <- desugaring; Task.ok and Task.err constructors; Task.map and Task.mapErr combinators); type system scope (tag union variants; closed vs open tag unions; record field access; type variable inference; Num type with Int and Dec constraints); platform scope (platform declaration; host function imports; effect model determined by platform; glue layer between Roc and host runtime; platform-provided stdout, file, http functions); function design scope (backslash lambda; |> pipe; backpassing for Task sequences; no mutable closures; partial application); and hour logging format (advisory category: Task, tag union, platform, backpassing; specific function call, type mismatch, and before/after type error count).
How should Roc developer retainer hours be logged?
Log each Roc retainer session with: advisory category (Task: Task.attempt argument order, Task.await sequencing, backpassing syntax; tag union: exhaustive matching, closed vs open variant design; platform: host function import, effect model, glue layer; backpassing: <- desugaring, sequential Task composition); the specific function call, type mismatch, and before/after type error count (function: Task.attempt; original call: Task.attempt (getUser id) \err -> handleError err; mismatch: lambda handles only err not full Result; type errors: 2/call; fix: Task.attempt (getUser id) (\result -> when result is Ok user -> ... Err err -> handleError err); type errors: 2/call → 0); and the before/after metric. Include whether fix required argument reordering, backpassing conversion, tag union restructuring, or platform interface redesign.