andrewMacmurray / elm-concurrent-task / ConcurrentTask

A Task similar to elm/core's Task but:

Built-in Tasks

Because elm-concurrent-task uses a different type to elm/core Task it's unfortunately not compatible with elm/core Tasks.

However, there are a number of tasks built into the JavaScript runner and supporting modules that should cover a large amount of the existing functionality of elm/core Tasks.

Check out the built-ins for more details:

Concurrent Tasks


type alias ConcurrentTask x a =
Internal.ConcurrentTask x a

A ConcurrentTask represents an asynchronous unit of work with the possibility of failure.

Underneath, each task represents a call to a JavaScript function and the runner handles batching and sequencing the calls.

define : { function : String, expect : Expect a, errors : Errors x, args : Json.Encode.Value } -> ConcurrentTask x a

Define a ConcurrentTask from an external JavaScript function with:

Say you wanted to interact with the node filesystem:

Define your task in Elm:

import ConcurrentTask exposing (ConcurrentTask)
import Json.Encode as Encode

type Error
    = Error String

readFile : String -> ConcurrentTask Error String
readFile path =
    ConcurrentTask.define
        { function = "fs:readFile"
        , expect = ConcurrentTask.expectString
        , errors = ConcurrentTask.expectThrows Error
        , args = Encode.object [ ( "path", Encode.string path ) ]
        }

And in your JavaScript runner:

import * as fs from "node:fs/promises"
import * as ConcurrentTask from "@andrewmacmurray/elm-concurrent-task";


const app = Elm.Main.init({});

ConcurrentTask.register({
  tasks: {
    "fs:readFile": (args) => fs.readFile(args.path),
  },
  ports: {
    send: app.ports.send,
    receive: app.ports.receive,
  },
});

A note on Errors:

The example fs:readFile Task has very simple error handling (turn any thrown exceptions into the Error type). This can be a great way to start, but what if you want more detailed errors?

The Errors section goes into more detail on different error handling strategies, including:

Expectations


type alias Expect a =
Internal.Expect a

Decode the response of a JS function into an Elm value.

expectJson : Json.Decode.Decoder a -> Expect a

Run a JSON decoder on the response of a Task

expectString : Expect String

Expect the response of a Task to be a String

expectWhatever : Expect ()

Ignore the response of a Task

Error Handling


type alias Errors x =
Internal.Errors x

The Errors type provides different ways to capture errors for a ConcurrentTask.

Understanding Errors

ConcurrentTask has two main kinds of Errors:

Error

This is the x in the ConcurrentTask x a and represents an expected error as part of your task flow. You can handle these with mapError and onError.

UnexpectedError

You can think of these as unhandled errors that are not a normal part of your task flow.

The idea behind UnexpectedError is to keep your task flow types ConcurrentTask x a clean and meaningful, and optionally lift some of them into regular task flow where it makes sense. Two hooks you can use for this are onResponseDecoderFailure and onJsException.

See the section on UnexpectedErrors for more details.

expectThrows : (String -> x) -> Errors x

The simplest Error handler. If a JS function throws an Exception, it will be wrapped in the provided Error type.

Maybe your JS function throws an AccessError:

import ConcurrentTask exposing (ConcurrentTask)

type Error
    = MyError String

example : ConcurrentTask Error String
example =
    ConcurrentTask.define
        { function = "functionThatThrows"
        , expect = ConcurrentTask.expectString
        , errors = ConcurrentTask.expectThrows MyError
        , args = Encode.null
        }

When the task is run it will complete with Task.Error (MyError "AccessError: access denied"). This can be transformed and chained using Task.mapError and Task.onError.

NOTE:

This kind of error handling can be useful to get started quickly, but it's often much more expressive and useful if you catch and explicitly return error data in your JS function that can be decoded with the expectError handler.

expectErrors : Json.Decode.Decoder x -> Errors x

Decode explicit errors returned by a ConcurrentTask. Use this when you want more meaningful errors in your task.

This will decode the value from an error key returned by a JS function, e.g.:

return {
  error: {
    code: "MY_ERROR_CODE",
    message: "Something Went Wrong",
  }
}

IMPORTANT NOTES:

Maybe you want to handle different kinds of errors when writing to localStorage:

import ConcurrentTask exposing (ConcurrentTask)
import Json.Decode as Decode
import Json.Encode as Encode

type WriteError
    = QuotaExceeded
    | WriteBlocked

set : String -> String -> Task WriteError ()
set key value =
    ConcurrentTask.define
        { function = "storage:set"
        , expect = ConcurrentTask.expectWhatever
        , errors = ConcurrentTask.expectErrors decodeWriteError
        , args =
            Encode.object
                [ ( "key", Encode.string key )
                , ( "value", Encode.string value )
                ]
        }

decodeWriteError : Decode.Decoder WriteError
decodeWriteError =
    Decode.string
        |> Decode.andThen
            (\reason ->
                case reason of
                    "QUOTA_EXCEEDED" ->
                        Decode.succeed QuotaExceeded

                    "WRITE_BLOCKED" ->
                        Decode.succeed WriteBlocked

                    _ ->
                        Decode.fail ("Unknown WriteError Reason: " ++ reason)
            )

And on the JS side:

ConcurrentTask.register({
  tasks: {
    "storage:set": (args) => setItem(args),
  },
  ports: {
    send: app.ports.send,
    receive: app.ports.receive,
  },
});


function setItem(args) {
  try {
    localStorage.setItem(args.key, args.value);
  } catch (e) {
    if (e.name === "QuotaExceededError") {
      return {
        error: "QUOTA_EXCEEDED",
      };
    } else {
      return {
        error: "WRITE_BLOCKED",
      };
    }
  }
}

expectNoErrors : Errors x

Only use this handler for functions that you don't expect to fail.

NOTE: If the decoder fails or the function throws an exception, these will be surfaced as UnexpectedErrors.

e.g. logging to the console:

import ConcurrentTask exposing (ConcurrentTask)

log : String -> ConcurrentTask x ()
log msg =
    ConcurrentTask.define
        { function = "console:log"
        , expect = ConcurrentTask.expectWhatever ()
        , errors = ConcurrentTask.expectNoErrors
        , args = Encode.string msg
        }

On the JS side:

ConcurrentTask.register({
  tasks: {
    "console:log": (msg) => console.log(msg),
  },
  ports: {
    send: app.ports.send,
    receive: app.ports.receive,
  },
});

Error Hooks

Lift UnexpectedErrors into regular task flow.

onResponseDecoderFailure : (Json.Decode.Error -> ConcurrentTask x a) -> ConcurrentTask x a -> ConcurrentTask x a

Use this alongside other error handlers to lift a ResponseDecoderFailure's Json.Decode error into regular task flow.

Maybe you want to represent an unexpected response as a BadBody error for a http request:

import ConcurrentTask exposing (ConcurrentTask)

type Error
    = Timeout
    | NetworkError
    | BadStatus Int
    | BadUrl String
    | BadBody Decode.Error

request : Request a -> ConcurrentTask Error a
request options =
    ConcurrentTask.define
        { function = "http:request"
        , expect = ConcurrentTask.expectJson options.expect
        , errors = ConcurrentTask.expectErrors decodeHttpErrors
        , args = encodeArgs options
        }
        |> ConcurrentTask.onResponseDecoderFailure (BadBody >> ConcurrentTask.fail)

onJsException : ({ message : String, raw : Json.Decode.Value } -> ConcurrentTask x a) -> ConcurrentTask x a -> ConcurrentTask x a

Use this to capture a raw JsException to lift it into the task flow.

NOTE: Tasks defined with expectThrows will never trigger this hook, make sure to only use it with expectErrors and expectNoErrors.

Transforming Errors

mapError : (x -> y) -> ConcurrentTask x a -> ConcurrentTask y a

Transform the value of an Error (like map but for errors).

onError : (x -> ConcurrentTask y a) -> ConcurrentTask x a -> ConcurrentTask y a

If the previous Task fails, catch that error and return a new Task (like andThen but for errors).

Chaining Tasks

succeed : a -> ConcurrentTask x a

A Task that succeeds immediately when it's run.

fail : x -> ConcurrentTask x a

A Task that fails immediately when it's run.

andThen : (a -> ConcurrentTask x b) -> ConcurrentTask x a -> ConcurrentTask x b

Chain the successful result of the previous Task into another one.

Maybe you want to do a timestamped Http request

import ConcurrentTask exposing (ConcurrentTask)
import ConcurrentTask.Http as Http
import ConcurrentTask.Time
import Time

task : ConcurrentTask Http.Error String
task =
    ConcurrentTask.Time.now
        |> ConcurrentTask.andThen (createArticle "my article")

createArticle : String -> Time.Posix -> ConcurrentTask Http.Error String
createArticle title time =
    Http.request
        { url = "http://blog.com/articles"
        , method = "POST"
        , headers = []
        , expect = Http.expectString
        , body = Http.jsonBody (encodeArticle title time)
        }

Convenience Helpers

These are some general helpers that can make chaining, combining and debugging tasks more convenient.

fromResult : Result x a -> ConcurrentTask x a

Create a Task from a Result error value. The task will either immediately succeed or fail when run.

Maybe you want to chain together tasks with CSV parsing:

import ConcurrentTask exposing (ConcurrentTask)
import Csv

task : ConcurrentTask Error CsvData
task =
    readFile |> ConcurrentTask.andThen parseCsv

parseCsv : String -> ConcurrentTask Error CsvData
parseCsv raw =
    Csv.decode decoder raw
        |> ConcurrentTask.fromResult
        |> ConcurrentTask.mapError CsvError

andThenDo : ConcurrentTask x b -> ConcurrentTask x a -> ConcurrentTask x b

Similar to andThen but ignores the successful result of the previous Task.

Maybe you want to save a file then log a message to the console:

import ConcurrentTask exposing (ConcurrentTask)

task : ConcurrentTask Error ()
task =
    saveFile |> ConcurrentTask.andThenDo (log "file saved")

return : b -> ConcurrentTask x a -> ConcurrentTask x b

Succeed with a hardcoded value after the previous Task.

Maybe you want to do some Tasks on a User but allow it to be chained onwards:

import ConcurrentTask exposing (ConcurrentTask)

saveUser : User -> ConcurrentTask Error User
saveUser user =
    saveToDatabase user
        |> ConcurrentTask.andThenDo (log "user saved")
        |> ConcurrentTask.return user

debug : (a -> String) -> (x -> String) -> ConcurrentTask x a -> ConcurrentTask x a

Debug the current state of a Task to the console.

This can be useful during development if you want to quickly peek at a Task:

import ConcurrentTask exposing (ConcurrentTask)


-- Prints to the console "Debug - Success: 130"
myTask : ConcurrentTask x Int
myTask =
    ConcurrentTask.succeed 123
        |> ConcurrentTask.map (\n -> n + 7)
        |> ConcurrentTask.debug Debug.toString Debug.toString

-- Prints to the console "Debug - Failure: 'error'"
myErrorTask : ConcurrentTask String Int
myErrorTask =
    ConcurrentTask.succeed 123
        |> ConcurrentTask.map (\n -> n + 7)
        |> ConcurrentTask.andThenDo (ConcurrentTask.fail "error")
        |> ConcurrentTask.debug Debug.toString Debug.toString

NOTE: Passing Debug.toString is useful to prevent shipping ConcurrentTask.debug calls to production, but any function that converts a task error or success value to a String can be used.

Batch Helpers

When you need to combine many tasks together.

Stack Safety

These helpers are carefully written to be stack safe. Use them if you're handling large lists of tasks (> 2000).

batch : List (ConcurrentTask x a) -> ConcurrentTask x (List a)

Perform a List of tasks concurrently (similar to Promise.all() in JavaScript) and return the results in a List.

If any of the subtasks fail the whole ConcurrentTask will fail.

sequence : List (ConcurrentTask x a) -> ConcurrentTask x (List a)

Perform a List of tasks one after the other and return the results in a List.

If any of the subtasks fail the whole ConcurrentTask will fail.

Maps

Transform values returned from tasks.

map : (a -> b) -> ConcurrentTask x a -> ConcurrentTask x b

Transform the value from a task.

Maybe you want to find what time it is in one hour.

import ConcurrentTask as exposing (ConcurrentTask)
import ConcurrentTask.Time
import Time

timeInOneHour : ConcurrentTask x Time.Posix
timeInOneHour =
    ConcurrentTask.map addOneHour ConcurrentTask.Time.now

addOneHour : Time.Posix -> Time.Posix
addOneHour time =
    Time.millisToPosix (Time.posixToMillis time + 60 * 60 * 1000)

andMap : ConcurrentTask x a -> ConcurrentTask x (a -> b) -> ConcurrentTask x b

Combine an arbitrary number of tasks together concurrently.

Maybe you want to load multiple pieces of config into a record:

import ConcurrentTask exposing (ConcurrentTask)

type alias Config =
    { dbConfig : DbConfig
    , yamlConfig : YamlConfig
    , envFile : EnvFile
    }

 loadConfig : ConcurrentTask Error Config
 loadConfig =
    ConcurrentTask.succeed Config
        |> ConcurrentTask.andMap loadDbConfig
        |> ConcurrentTask.andMap loadYamlConfig
        |> ConcurrentTask.andMap loadEnvFile

map2 : (a -> b -> c) -> ConcurrentTask x a -> ConcurrentTask x b -> ConcurrentTask x c

Run two tasks concurrently and combine their results.

import ConcurrentTask exposing (ConcurrentTask)
import ConcurrentTask.Time
import Time

loadUserAndTime : ConcurrentTask Error ( User, Time.Posix )
loadUserAndTime =
    ConcurrentTask.map2 Tuple.pair loadUser ConcurrentTask.Time.now

map3 : (a -> b -> c -> d) -> ConcurrentTask x a -> ConcurrentTask x b -> ConcurrentTask x c -> ConcurrentTask x d

Run three tasks concurrently and combine their results.

map4 : (a -> b -> c -> d -> e) -> ConcurrentTask x a -> ConcurrentTask x b -> ConcurrentTask x c -> ConcurrentTask x d -> ConcurrentTask x e

Run four tasks concurrently and combine their results.

map5 : (a -> b -> c -> d -> e -> f) -> ConcurrentTask x a -> ConcurrentTask x b -> ConcurrentTask x c -> ConcurrentTask x d -> ConcurrentTask x e -> ConcurrentTask x f

Run five tasks concurrently and combine their results.

Run a Task

Once you've constructed a Task it needs to be passed to the runner to perform all of the effects.

Here's a minimal complete example:

A task to fetch 3 resources concurrently:

type alias Titles =
    { todo : String
    , post : String
    , album : String
    }

getAllTitles : ConcurrentTask Http.Error Titles
getAllTitles =
    ConcurrentTask.map3 Titles
        (getTitle "/todos/1")
        (getTitle "/posts/1")
        (getTitle "/albums/1")

getTitle : String -> ConcurrentTask Http.Error String
getTitle path =
    Http.request
        { url = "https://jsonplaceholder.typicode.com" ++ path
        , method = "GET"
        , headers = []
        , body = Http.emptyBody
        , expect = Http.expectJson (Decode.field "title" Decode.string)
        }

A program to run the task:

port module Example exposing (main)

import ConcurrentTask exposing (ConcurrentTask)
import ConcurrentTask.Http as Http
import Json.Decode as Decode

type alias Model =
    { tasks : ConcurrentTask.Pool Msg Http.Error Titles
    }

type Msg
    = OnProgress ( ConcurrentTask.Pool Msg Http.Error Titles, Cmd Msg )
    | OnComplete (ConcurrentTask.Response Http.Error Titles)

init : ( Model, Cmd Msg )
init =
    let
        ( tasks, cmd ) =
            ConcurrentTask.attempt
                { send = send
                , pool = ConcurrentTask.pool
                , onComplete = OnComplete
                }
                getAllTitles
    in
    ( { tasks = tasks }, cmd )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        OnComplete response ->
            let
                _ =
                    Debug.log "response" response
            in
            ( model, Cmd.none )

        OnProgress ( tasks, cmd ) ->
            ( { model | tasks = tasks }, cmd )

subscriptions : Model -> Sub Msg
subscriptions model =
    ConcurrentTask.onProgress
        { send = send
        , receive = receive
        , onProgress = OnProgress
        }
        model.tasks

port send : Decode.Value -> Cmd msg

port receive : (Decode.Value -> msg) -> Sub msg

main : Program {} Model Msg
main =
    Platform.worker
        { init = always init
        , update = update
        , subscriptions = subscriptions
        }

attempt : { pool : Pool msg x a, send : Json.Decode.Value -> Platform.Cmd.Cmd msg, onComplete : Response x a -> msg } -> ConcurrentTask x a -> ( Pool msg x a, Platform.Cmd.Cmd msg )

Start a ConcurrentTask. This needs:

Make sure to update your Model and pass in the Cmd returned from attempt. e.g. in a branch of update:

let
    ( tasks, cmd ) =
        ConcurrentTask.attempt
            { send = send
            , pool = model.pool
            , onComplete = OnComplete
            }
            myTask
in
( { model | tasks = tasks }, cmd )


type Response x a
    = Success a
    | Error x
    | UnexpectedError UnexpectedError

The value returned from a task when it completes (returned in the OnComplete msg).

Can be either:


type UnexpectedError
    = UnhandledJsException ({ function : String, message : String, raw : Json.Decode.Value })
    | ResponseDecoderFailure ({ function : String, error : Json.Decode.Error })
    | ErrorsDecoderFailure ({ function : String, error : Json.Decode.Error })
    | MissingFunction String
    | InternalError String

This error will surface if something unexpected has happened during the task flow.

Catchable Errors

These errors will be surfaced if not handled during task flow:

Uncatchable Errors

These errors will always surface, as they are assumed to have no meaningful way to recover from during regular task flow:

onProgress : { send : Json.Decode.Value -> Platform.Cmd.Cmd msg, receive : (Json.Decode.Value -> msg) -> Platform.Sub.Sub msg, onProgress : ( Pool msg x a, Platform.Cmd.Cmd msg ) -> msg } -> Pool msg x a -> Platform.Sub.Sub msg

Subscribe to updates from the JavaScript task runner.

This needs:

You can wire this in like so:

subscriptions : Model -> Sub Msg
subscriptions model =
    ConcurrentTask.onProgress
        { send = send
        , receive = receive
        , onProgress = OnProgress
        }
        model.tasks

Make sure to update your Model and pass in the Cmd in your OnProgress branch in update:

OnProgress ( tasks, cmd ) ->
    ( { model | tasks = tasks }, cmd )


type alias Pool msg x a =
Internal.Pool msg x a

A Pool keeps track of each task's progress, and allows multiple Task attempts to be in-flight at the same time.

pool : Pool msg x a

Create an empty ConcurrentTask Pool.

Right now it doesn't expose any functionality, but it could be used in the future to do things like: