Foldkit — The Elm Architecture in TypeScript, powered by Effect

A few years ago, I worked on an Elm app at thoughtbot and it completely changed how I think about building software. I’ve been chasing that feeling ever since. Unfortunately, it’s been hard to convince managers to adopt Elm, despite its myriad benefits.

I mostly work with TypeScript and Scala professionally. Lots of React the last several years. I have spent a lot of time bringing Elm-ish patterns into my React work. I realized about a year ago that Effect makes “The Elm Architecture in TypeScript” genuinely viable, so I decided to build it.

The result is Foldkit (website) (repo).

Here’s a complete counter application in Foldkit.

import { Match as M, Schema as S } from 'effect'
import { Runtime } from 'foldkit'
import { Command } from 'foldkit/command'
import { Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'

// MODEL

const Model = S.Struct({ count: S.Number })
type Model = typeof Model.Type

// MESSAGE

const ClickedDecrement = m('ClickedDecrement')
const ClickedIncrement = m('ClickedIncrement')
const ClickedReset = m('ClickedReset')

const Message = S.Union(ClickedDecrement, ClickedIncrement, ClickedReset)
type Message = typeof Message.Type

// UPDATE

const update = (
  model: Model,
  message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
  M.value(message).pipe(
    M.withReturnType<[Model, ReadonlyArray<Command<Message>>]>(),
    M.tagsExhaustive({
      ClickedDecrement: () => [{ count: model.count - 1 }, []],
      ClickedIncrement: () => [{ count: model.count + 1 }, []],
      ClickedReset: () => [{ count: 0 }, []],
    }),
  )

// INIT

const init: Runtime.ElementInit<Model, Message> = () => [{ count: 0 }, []]

// VIEW

const { div, button, Class, OnClick } = html<Message>()

const view = (model: Model): Html =>
  div(
    [
      Class(
        'min-h-screen bg-white flex flex-col items-center justify-center gap-6 p-6',
      ),
    ],
    [
      div(
        [Class('text-6xl font-bold text-gray-800')],
        [model.count.toString()],
      ),
      div(
        [Class('flex flex-wrap justify-center gap-4')],
        [
          button([OnClick(ClickedDecrement()), Class(buttonStyle)], ['-']),
          button([OnClick(ClickedReset()), Class(buttonStyle)], ['Reset']),
          button([OnClick(ClickedIncrement()), Class(buttonStyle)], ['+']),
        ],
      ),
    ],
  )

// STYLE

const buttonStyle = 'bg-black text-white hover:bg-gray-700 px-4 py-2 transition'

// RUN

const element = Runtime.makeElement({
  Model,
  init,
  update,
  view,
  container: document.getElementById('root')!,
})

Runtime.run(element)

A few other things that Elm developers will find familiar:

  • Commands — side effects are described as Effects that return Messages. The runtime executes them.
  • Subscriptions — declared as a function of the Model. The runtime diffs and manages them.
  • Bidirectional routing — URLs parse into typed routes and routes build back into URLs.
  • Submodels + OutMessage — child modules communicate up through an explicit message pattern.
  • DevTools with time travel — built-in overlay for inspecting Messages and Model state, with the ability to jump to any point in history.

What’s different from Elm:

  • It’s TypeScript, so you’re in the npm ecosystem with all that implies.
  • Effect gives you a real effect system, but it’s a library, not a language feature — the compiler won’t stop you from doing side effects in your update function. The architecture is enforced by convention and code review, not by the compiler. This is the biggest tradeoff.
  • Views use a virtual DOM (Snabbdom) with an HTML builder API rather than a template language.

Curious what people here think. In the nine months since starting Foldkit, I’ve shared progress updates in the Effect Discord, but have never shared here because, while Foldkit is basically a love letter to Elm, it’s not Elm itself.

Thank you!

12 Likes

I don’t want to detract from what you have created, as I am sure you have worked hard to create it and it may certainly have its strengths and an audience that appreciate it. And maybe things are worth pursuing for their own reasons and motivations rooted in intellectual curiosity… so don’t take this as criticism but I have to ask …

Why not just use Elm instead of imitating it in a less convenient way ? Is it because you work somewhere that cannot be convinced that Elm would be a better choice? Or is it because by writing directly in typescript the whole JS eco-system of libraries is available to use without FFI friction, which is a legitimate concern when choosing Elm ? Or some other reason…?

It’s a great question, thanks for asking. And the disclaimer is kind, but criticism is okay, too.

A few years ago in my work, I lobbied pretty hard to rewrite an Angular application in Elm. We had really good reasons to use Elm (runtime guarantees were a major win, team was very FP-minded), but there were concerns about long-term maintainability, hiring pool, ease of interoperability, etc. So we built it in React with fp-ts and RxJS.

I’m sure many others have found themselves in similar situations. I can’t be the only person working on TypeScript frontends who is unenthusiastic about the current suite of frontend options.

I love Elm. At the same time, I want to be able to work with the underlying architecture in TypeScript. TypeScript doesn’t have a framework like this yet. I think it might be helpful to reframe Foldkit less as, “This is better than Elm” (it’s not in many important ways) and more as, “This might be better for your project than all the TypeScript alternatives.”

A few other points.

I don’t see Foldkit as an imitation of Elm. I see it as using the same underlying architectural principles as Elm, while also being its own thing: a frontend framework, built on Effect, that uses The Elm Architecture.

Yes, Foldkit sidesteps the FFI friction you mentioned.

I think convenience is subjective. I love using Effect. So Foldkit is very convenient in the sense that it’s built on Effect and designed to be used with it. I can use Effect RPC on the backend and Foldkit on the frontend and it just… works. Elm would be less convenient in that way. Also, Effect is growing rapidly and lots of folks are building their backends on it. The principled frontend piece is missing.

Hope I clarified my thinking here. I really do appreciate any thoughts or feedback.

Yes, thank you.

I am just trying to understand this unwritten rule that seems to exist in much of the commercial world - “Elm is wonderful, but we are not allowed to use it, because <insert weak reason here>…”!

FFI and not being able to run the same code on front-end as back-end are probably not so weak reasons, depending on context, and engineering is always full of pragmatic compromises anyway.

Worth noting that whilst direct FFI is convenient, Elms lack of it maintains strict functional purity - which is a kind of convenience in itself due to its advantages in security, safety, correctness, optimization, reasoning about code and so on. My own belief is that as we erode these things by coding more and more with AI, the value of very strict compilers such as Elm has the potential to become elevated.

I have also run the same Elm code on front end and back end (in production) by using Platform.worker and running under Node. Plenty things about that which are not ideal, but I think it could really help Elm if there was a better out of the box story for server side.

1 Like

Shameless plug: if you want to use the pipe operator in JavaScript, have a look at GitHub - laurentpayot/verticalize: A pipe-like function to verticalize your JavaScript code · GitHub :wink:

2 Likes

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