DEV Community

Cover image for Why I'm Building a Typed Workflow Language Instead of Writing Glue Code
Brock Claussen
Brock Claussen

Posted on • Originally published at foxhole.hashnode.dev

Why I'm Building a Typed Workflow Language Instead of Writing Glue Code

Post 1 was the diagnosis: workflow logic keeps wanting to be more structured than ordinary glue code, but less heavy than a full workflow platform. The contract is hiding in the implementation, and that's where the production incidents come from.

This one is about what should replace it.

I didn't want to start by building a control plane. I wanted the map first.

A way to describe a workflow as a contract:

  • these are the states
  • these are the transitions
  • this is the data each state carries
  • these are the side effects
  • these are the committed actions
  • this is the code the host application must provide

Then I wanted that contract to compile into boring, ordinary code.

That's the shape of Gust: a typed state-machine language that currently compiles to Rust and Go.

From code path to contract

A small workflow can start as a straightforward code path:

func HandleOrder(ctx context.Context, payload Payload) error {
    order, err := parseOrder(payload.Body)
    if err != nil {
        return markFailed("parse", err)
    }

    message := formatSlackMessage(order)

    if err := postSlack(ctx, message); err != nil {
        return markFailed("notify", err)
    }

    return markComplete(order.ID)
}
Enter fullscreen mode Exit fullscreen mode

There's nothing obviously wrong with this function. For a small feature, it might be exactly the right code.

But as the workflow grows, the contract gets harder to see.

Where are the states? Which errors can retry? What data exists after parse but before notify? Is postSlack safe to repeat? If the process dies after posting but before marking complete, what should happen? Can a caller skip directly from received to notified? Does the UI know the same states the worker knows?

You can answer those questions with discipline, tests, comments, and docs. Those answers live outside the workflow itself. Six months later they live in someone's head. A year later they don't live anywhere.

Gust exists because I want the workflow contract to carry those answers directly.

Why not just JSON or YAML?

One possible answer is a declarative graph format. Put the states and edges in JSON or YAML, then let a runtime interpret the graph.

That can work. It's also the direction many workflow tools naturally take.

The tradeoff is that a graph file usually becomes another runtime input rather than a typed contract. The interesting behavior moves somewhere else: custom handler code, embedded expressions, loosely typed payloads, stringly named effects, runtime-only validation. You end up debugging a graph by guessing what the runtime did with it, which is the exact problem we were trying to escape.

Gust is trying to keep the contract closer to code without making the workflow disappear into ordinary application functions. The workflow stays explicit enough to validate and generate from, but it compiles into host-language code that Rust and Go projects can own.

That's the middle ground I want. More structure than glue code. Less platform gravity than a full interpreted workflow engine.

What a Gust machine says explicitly

Here's what that looks like in source. A Gust workflow is a state machine:

type OrderPayload {
    order_id: String,
    customer: String,
    total_cents: i64,
    currency: String,
    items_count: i64,
}

machine OrderNotificationWorkflow {
    state Idle
    state WebhookReceived(body: String, source_ip: String)
    state OrderParsed(order: OrderPayload, original_body: String)
    state MessageFormatted(order: OrderPayload, slack_text: String, original_body: String)
    state NotificationSent(order_id: String, slack_ts: String)
    state Failed(step: String, reason: String, original_body: String)

    transition receive: Idle -> WebhookReceived
    transition parse: WebhookReceived -> OrderParsed | Failed
    transition format: OrderParsed -> MessageFormatted | Failed
    transition notify: MessageFormatted -> NotificationSent | Failed

    effect parse_order_json(body: String) -> OrderPayload
    effect format_slack_message(order: OrderPayload) -> String
    action post_slack(channel: String, text: String, credential_id: String) -> String
}
Enter fullscreen mode Exit fullscreen mode

That file isn't an implementation detail. It's the contract.

It says which states exist. It says which transitions are allowed. It says what data is available in each state. It says which side-effectful calls the host runtime must implement. It says which operation is an externally visible action.

Handler bodies fill in the transition logic, but the shape above is already useful. The compiler can validate pieces of that contract before the runtime ever sees it.

Why compile instead of interpret?

I didn't want the first version of Gust to be a giant runtime.

That matters. Runtimes attract scope. Once the runtime owns everything, every design question becomes a platform question. Every feature request becomes a hosted product feature request. That's a different project, with a different burn rate, and I'm not ready to commit to that project yet.

The current approach is deliberately conservative: Gust compiles .gu source into host-language code. Rust output gets enums, machine structs, transition methods, effect traits, and serialization support. Go output gets state constants, state data structs, transition methods, interfaces, and JSON tags.

That keeps the generated code close to the systems I already want to run. It also makes Gust useful as a companion language rather than a replacement for Rust or Go. The host application still owns real I/O, credentials, deployment, observability, and business integrations. Gust owns the workflow contract and the generated state-machine layer.

That split matters. It keeps the language smaller and makes adoption less all-or-nothing. Pulling Gust out later — if it doesn't earn its keep — should be a deletion, not a migration.

Effects are part of the interface

The most important part of the language might not be the state syntax. It's the effect boundary.

In Gust, side effects are declared:

effect parse_order_json(body: String) -> OrderPayload
effect format_slack_message(order: OrderPayload) -> String
action post_slack(channel: String, text: String, credential_id: String) -> String
Enter fullscreen mode Exit fullscreen mode

The generated code requires the host runtime to provide implementations for those calls.

Which means the workflow doesn't secretly talk to Slack, the database, or an HTTP API. The contract names the call. The runtime binds it.

The newer action distinction exists for workflow runtimes like Corsac. A runtime can treat normal effect calls as replay-safe or non-committing, while treating action calls as externally visible work that must not be repeated casually.

That's the kind of contract glue code usually leaves implicit. Implicit is fine until the day a payment gets charged twice and someone has to explain it on a call.

What the compiler can catch

Gust is at v0.2 — still young — but the compiler already applies useful validation pressure across three areas:

  • Structure: duplicate state and transition names, unknown states in transitions, unreachable states.
  • Flow: missing handlers, invalid goto targets, mismatched goto argument counts, branch termination warnings.
  • Safety: effect argument arity, selected expression type mismatches, action handler-safety warnings.

None of that makes workflow bugs impossible. But it moves basic contract mistakes out of the runtime path and into the edit/build loop.

That's the point.

I don't want to discover during a production run that a transition targets a state nobody declared, or that a handler can fall through without a state transition, or that a committed action is followed by more side-effectful work in the same path. Those bugs have always been catchable. They just usually weren't caught.

What Gust is and isn't

Gust isn't a hosted workflow product. It isn't a queue, scheduler, visual designer, deployment system, or run database. Those are runtime and product concerns. Corsac is where I'm exploring some of that operational surface.

It also isn't trying to replace Rust or Go. The host language still matters. The generated code should feel like code you could have written, but didn't want to maintain by hand. A small language has to justify every feature it adds, because every feature is one more thing to teach, document, and not regret.

The long-term question is how far Gust should go. It can stay a focused state-machine DSL and still be valuable. The current roadmap leaves room for a broader direction — free functions, stronger type checking, contracts, effect polymorphism, more owned semantics instead of passing work to target-language compilers — but I don't want to rush that. The useful thing right now is narrower: prove that typed workflow contracts can generate real code, support real tooling, and give a runtime like Corsac enough structure to operate compiled workflows.

That's why I'm building a typed workflow language instead of writing more glue code. The glue code still exists. It just moves to the boundary where it belongs: effect implementations, runtime adapters, deployment scripts, product-specific integrations.

The next post walks through what a .gu file actually buys, line by line.

Top comments (0)