Lask — a task runner with its own typed language, written in Haskell

This is my first post here, and I’m afraid it’s about my own project — I hope that’s an acceptable way to introduce myself.

What I made

Lask is a task runner. It replaces Makefiles and CI YAML with a small typed language in which every command is bound to a container image:

command "go" on #golang:1.22
command "npm" on #node:20

test_api(): String = $ go test ./...
test_web(): String = $ npm test

lask run test_api on my laptop and the same line in CI run in the same pinned image. lask check resolves every name, argument and type across the whole file before anything executes, and the same checker runs as a language server.

I’ve just tagged 0.5.0. It’s MIT, pre-1.0, roughly 12k lines of Haskell, and ships as static binaries for macOS, Linux and Windows, so users never need a Haskell toolchain.

brew install lask

Why I chose Haskell for it

Three reasons, and they’ve all held up over the two years I’ve been working on this.

Development guarded by types is simply pleasant. That sounds soft, but it’s the reason I kept going on a side project through the evenings when I had no particular obligation to. Making a change and having the compiler walk me through the consequences is a good way to spend a limited amount of attention.

The types let me get away with far fewer tests, especially early on. A large class of mistakes couldn’t survive compilation, so I didn’t need a test to catch them, and I got usable quality without building a test suite before I’d even decided what the language was. For a project that changed shape every few weeks at the start, not having to maintain tests against a design I was about to throw away mattered a lot. (I do have tests now, and they catch the things types can’t — logic errors in the elaborator, mostly.)

The parser combinators made the whole thing tractable. I had written parsers with lex and yacc before, and the difference is not marginal — it is the difference between a project I would start on a weeknight and one I wouldn’t. There is no separate grammar file, no generation step in the build, and no generated code to read when something goes wrong. The parser is ordinary Haskell, so I factor it with ordinary functions, and the grammar ends up reading close to the specification I wrote it from. I never once had to work out what a shift/reduce conflict was trying to tell me. That mattered most in the first months, when the language was still changing shape every few weeks: with combinators, changing the grammar is just editing a function.

Where it is now

These days a large share of the implementation is written by generative AI, and I spend my time on the specification and on review. What surprises me is the quality: compared with the other mainstream languages I’ve tried this with, what comes back in Haskell holds up much better once I actually run it. I assume the types are doing most of that work — a wrong edit here usually fails to compile, and in a dynamically typed language it usually doesn’t.

Which leads to the thing I find myself hoping for. The usual objection to Haskell has always been a human cost: the learning curve, the unfamiliarity. That cost was attached to humans writing the code. If we’re writing less of it by hand, the objection weakens while the benefit — the compiler refusing to let a bad edit through — doesn’t weaken at all. I’d like to think Haskell turns out to be the right choice for the age of generative AI, and becomes a more ordinary one to make.

Feedback of any kind is welcome, including on the parts that are rough — there are plenty.

3 Likes