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.