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

**URL:** <https://discourse.haskell.org/t/lask-a-task-runner-with-its-own-typed-language-written-in-haskell/14713>\
**Category:** Show and Tell\
**Created:** [September 20, 2026, 10:05am UTC](https://discourse.haskell.org/t/lask-a-task-runner-with-its-own-typed-language-written-in-haskell/14713 "2026-09-20T10:05:05Z")\
**Posts on this page:** 5\
**Page:** 1

<div class="post-metadata">

**Author:** ![torudev](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.haskell.org/torudev/32/5792_2.png) [@torudev](https://discourse.haskell.org/u/torudev)\
**Post date:** [September 20, 2026, 10:05am UTC](https://discourse.haskell.org/t/lask-a-task-runner-with-its-own-typed-language-written-in-haskell/14713/1 "2026-09-20T10:05:06Z")

</div>

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:

```haskell
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.

```haskell
brew install lask

```

> **[GitHub - lask-task-runner/lask: A task runner with a small typed language: tasks...](https://github.com/lask-task-runner/lask)**
>
> A task runner with a small typed language: tasks run in pinned containers and are checked before anything executes.

### 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.

---

<div class="post-metadata">

**Author:** ![blamario](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.haskell.org/blamario/32/860_2.png) [@blamario](https://discourse.haskell.org/u/blamario)\
**Post date:** [October 6, 2026, 7:50pm UTC](https://discourse.haskell.org/t/lask-a-task-runner-with-its-own-typed-language-written-in-haskell/14713/2 "2026-10-06T19:50:55Z")

</div>

This doesn’t seem right in the README:

> A task’s signature is its command line — `release(--dry_run = false)` is `lask run release --dry-run true`

I think the idea of the project is admirable. You’ll find it difficult to attract users though, vibe-coded tools are dime a dozen nowadays. If you’re serious, you could start by adding more examples: how to replace a `Makefile`, how to improve an existing read-world GitHub CI pipeline, etc.

Also: I understand that using a parser combinator library is a joy, but could the same language be expressed as an EDSL? I’m not suggesting that you should drop the parser, just that the parsed language (PDSL) could be a thin wrapper over the EDSL. The EDSL could then be released as a Haskell library others could build on, and it should also be easier to test in some ways.

---

<div class="post-metadata">

**Author:** ![torudev](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.haskell.org/torudev/32/5792_2.png) [@torudev](https://discourse.haskell.org/u/torudev)\
**Post date:** [October 7, 2026, 2:16pm UTC](https://discourse.haskell.org/t/lask-a-task-runner-with-its-own-typed-language-written-in-haskell/14713/3 "2026-10-07T14:16:33Z")

</div>

Thanks for taking the time. this is the most useful feedback the project has had!

**README.** Fixed, thanks: [docs: compare a task call with its command line in the README · lask-task-runner/lask@ef9387c · GitHub](https://github.com/lask-task-runner/lask/commit/ef9387ca96091871084d01983241bbdf1bb08d7a) — let me know if anything else looks off.

**Examples.** I appreciate your suggestion. I’ll add migration examples starting from common setups: a typical Makefile, and a real-world GitHub Actions workflow.

**EDSL.** I’d love to try that. Considering EDSL, I ran into two questions.

First, evaluation strategy. If tasks are plain Haskell functions over `E a`, calls get inlined at construction time. Lask allows recursion, so a recursive task whose base case is decided at run time builds an infinite term. And since commands are effects, a Haskell `let` around one duplicates it, so the command runs twice. Named declarations with explicit `call` and `bind` fix both, but that drifts back toward the surface syntax. Would observable sharing in the style of `data-reify` be worth it here, or is explicit naming the right answer for a language where calls are effects?

Second, `Any` type expression. Lask’s types are structural, with `Any` and untagged unions and no unifier, so I’m not sure how much maps onto Haskell’s types. My plan would be to start by running the existing checker over EDSL-built terms.

I’d value your view on the first question in particular.

---

<div class="post-metadata">

**Author:** ![blamario](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.haskell.org/blamario/32/860_2.png) [@blamario](https://discourse.haskell.org/u/blamario)\
**Post date:** [October 8, 2026, 7:09pm UTC](https://discourse.haskell.org/t/lask-a-task-runner-with-its-own-typed-language-written-in-haskell/14713/4 "2026-10-08T19:09:32Z")

</div>

As the author of `grammatical-parsers` I’m delighted to see them suggested as a solution in the PR, but they come with some heavy requirements: you’d have to declare all your functions as fields of a HKD record. Have you first considered monads, applicatives, arrows, or PHOAS instead? There’s a rich literature on embedding a language in Haskell. Since most functions in your language are effectful, applicatives are the first thing I’d reach for: design an applicative functor (which may or may not also be a monad) that statically tracks everything you need.

---

<div class="post-metadata">

**Author:** ![torudev](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.haskell.org/torudev/32/5792_2.png) [@torudev](https://discourse.haskell.org/u/torudev)\
**Post date:** [October 10, 2026, 5:34am UTC](https://discourse.haskell.org/t/lask-a-task-runner-with-its-own-typed-language-written-in-haskell/14713/5 "2026-10-10T05:34:40Z")

</div>

Thanks, this changed the design. I dropped the HKD record and went with PHOAS. Lask needs the whole program before running anything, but the language itself is monadic: commands depend on earlier output. An applicative would keep things static only by forbidding that. PHOAS keeps the term inspectable under every binder.

```haskell
fact :: Task '["n" ::: 'TNumber] 'TNumber
fact = task "fact" $ \n -> if_ (n ==. 0) 1 (n * call fact (n - 1))

lintChanged :: Task '[] 'TVoid
lintChanged = task "lint_changed" $
  forEach (call changed) $ \f -> run npm ("exec eslint -- " <> f)

```

Applicatives are still used where the structure is static: keyword parameters, and parallel composition. The parser isn’t built on the EDSL. Both front ends lower to the same Core, which is where they meet, and a test checks that they agree. It’s experimental and covers a subset of the language: [feat: embed the language in Haskell as an experimental EDSL by Ikepeeee · Pull Request #111 · lask-task-runner/lask · GitHub](https://github.com/lask-task-runner/lask/pull/111)

Next, I plan to extend it to the rest of the language and add a checker over Core that both front ends share.
