Proposal: coeffects for Elm

slightly inaccurate TLDR: type-safe dynamic scoping

give this a read for context: Coeffects: Context-aware programming languages

Coeffects are essentially a way of reasoning about things that are required from the context by program. For example, Elm programs reside in the browser, which has a viewport dimension: width by height. UI elements in an Elm program may require the viewport dimensions, for e.g. responsive design. Coeffects let the compiler reason about the fact that an element needs to get the viewport from its context.

Without coeffects, passing the viewport into a component is trivial enough:

responsiveThing : (Int, Int) -> ... -> Html msg
responsiveThing dimensions ... =
    ...

Simple enough. The problem begins when you already have an existing codebase, and want to make a non-responsive element responsive.

view model =
    table model.data

table data =
    List.map row data

row rowData =
    [ cell rowData.foo
    , cell rowData.bar
    , button rowData.baz "hi" (DeleteClicked rowData.id)
    ]

button data text msg =
    ...

Say you want to make button responsive to the viewport size, increasing its dimensions on mobile form-factors, or maybe you want to show an icon instead of a full-text label on mobile.

Now you have to go through every function that leads to button somehow, passing in the viewport from the model.

view model =
    table model.viewport model.data

table viewport data =
    List.map (row viewport) data

row viewport rowData =
    [ cell rowData.foo
    , cell rowData.bar
    , button viewport rowData.baz "hi" (DeleteClicked rowData.id)
    ]

button viewport data text msg =
    ...

You’ve changed a lot of lines other than the element you wanted to change, cluttering up the git diff. The new argument may also fit awkwardly into already existing dataflow between functions, using partial application, >>/<<, etc.

While the compiler does give you a nice trail to follow when you make changes like these, letting you be confident that you didn’t miss any of the pieces, following the trail it lays out takes non-trivial time that could be better spent actually working on the program instead of wiring data through it.

Wouldn’t it be nice if we could punt the job of wiring data through long chains of function calls to the computer, so we could spend less time manually piping variable through function calls and more time working on high-level stuff?

That’s exactly what coeffects do: give the computer the necessary tools to reason about wiring data through functions through their context in a type-safe manner, freeing programmers from having to do that themselves.

Given a function like:

button data text msg =
    if dimensionsAreMobile @viewport then
        ...
    else
        ...

a coeffectful system would be able to infer that the expression needs a viewport: (Int, Int) in its context. Since the expression’s environment doesn’t provide a viewport, the compiler infers the need for one in the function’s context. If the function was called from a context that lacked a viewport: (Int, Int), the compiler raises an error.

The rest of this post will explore various options for the syntax needed to express coeffects in Elm for three areas:

  • signatures of coeffectful functions
  • using a context variable
  • introducing a context variable

Signatures

button : Data -> String -> msg -> Html msg @ viewport : (Int, Int)

This reads somewhat naturally, “button takes data, string, msg, and returns html msg AT a context with viewport of (Int, Int).”

It suffers from lack of visual separation.

button : Data -> String -> msg -> Html msg
       @ viewport : (Int, Int)

Maybe a multiline syntax would look better.

Alternative characters to use would be ^, or perhaps a keyword context. Coeffects could be listed in a record-style notation, like @ { viewport : (Int, Int) }. Maybe they could even use type aliases:

type alias Context =
  { viewport : (Int, Int)
  , darkMode : Bool
  }

button : Data -> String -> msg -> Html msg @ Context

Usage

I think for “code using this feature shouldn’t spend a lot of characters on it every time,” the only feasible option is a sigil. The linked explanation page uses ?variable, and I think @variable looks nice as well. There’s a lot of unused characters that can become a sigil, like $, ~, etc.

There’s also the possibility of introducing a new keyword, though this would break existing code. Wouldn’t recommend.

button =
  if isMobileViewport (context viewport) then
    ...
  else
    ...

Introducing Variables Into The Context

I think there’s two main options:

sigilled let, syntax-compatible:

  let
      ?viewport =
          model.viewport
  in
  tableView model.data

or

new keyword:

  introduce
    viewport =
      model.viewport
  in
  tableView model.data

Same discussion as with sigil or keyword for access. Sigil is backwards-compatible, keyword is not.

1 Like

Hey there @pontaoski, thanks for the food for thought and this proposal!

(I feel like I’ll sound a bit dismissive here – please don’t take it that way – I’m glad for your post and I’m just thinking aloud and trying to give this a fair chance in my head without a way to try it hands-on :slight_smile: )

Having not yet read the paper I’m a bit worried about “spooky action at a distance” (changing a top-level context and getting an error somewhere in the leaf parts of the program), or non-obvious “what context do I have here” situations if we don’t use type annotations everywhere.

On the other hand, if we do use type annotations everywhere, it feels to me like this proposal loses a bit of it’s appeal compared to an explicit function argument - you still need to mention the context on every step of the way from (some) root to the leaf that needs it, right?


Thinking about this snippet of new syntax:

I think it’s a little unintuitive / unclear on first sight how does this let ?foo = ... in ... interact with how let currently works: bindings can depend on each other (in arbitrary order). Can I mix context bindings and normal bindings in the same let..in? Can context bindings depend on each other? Does the context binding only apply to the let body, or also to the other let bindings?

Also to add to this, you can already do this with Elm in the present. For instance, there is

https://package.elm-lang.org/packages/miniBill/elm-ui-with-context/1.1.0/

But the basic trick is that you add the context to the top of the view tree, then pass it down via lambdas:

type MyHtml context msg =
     MyHtml (context -> Html msg)

div : List (Attribute msg) -> List (MyHtml context msg) -> MyHtml context msg
div attrs children =
    MyHtml (\context -> 
        Html.div attrs (List.map (\(MyHtml child) -> child context) children)
    )

with : (context -> MyHtml context msg) -> MyHtml context msg
with fn =
     MyHtml (\context ->
        let 
           (MyHtml result) = fn context
        in
        result context
     )

render : context -> MyHtml context msg -> Html msg
render context (MyHtml html) =
    html context

(This lambda implementation is the way to do it if you are wrapping an existing rendering system like elm/html or mdgriffith/elm-ui. At work we use a two-pass rendering system where the user’s view functions produce a list of our custom types, then these get transformed into Html in a second pass; which is where the context injection can happen, making the code a lot more straightforward).

6 Likes

When you go through something like this you eventually understand that even your “large app” is not actually that big and complex in its structure. Such exposure of the inner complexity leads to a deeper understanding of what you are doing. The top-level “context” passed downstream to the “button” is also the context for everything else surrounding this button. It is consideration of the requirements and planning ahead that helps.

@Context - Looks a lot like Angular annotations and dependency injection to me. Tried to learn Angular 4 at one point, gave up when I realised I needed to understand what obscure thing 20 or so badly documented and mysterious annotations do.

I really like the way Elm makes everything absolutely explicit even though it takes a little more work sometimes to do it.

2 Likes

Especially if you use extensible records for the context, it’s very similar, with different view functions being able to say they only depend on certain things from the context.

We tried this context approach at our company, when we needed to support multiple languages. We also used it to pass down window dimensions and image urls from flags generated by the bundler. Half a year later we removed it though and instead passed the stuff as arguments, due to people having trouble with confusing type errors. In retrospect though I think the main reason for the type problems was that we had been using extensible records for the context, which might not actually have been necessary.

I think if you just have a single context for your whole app, and don’t do stuff like “mapping” the context, you’d avoid most confusion. Like having a set of global variables which can be read in many places but only updated in one place.

1 Like

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.