Using Mill is incredibly simple to get started with, but if you reach a point where you're defining your own tasks and/or writing plugins you're likely to hit on some instances where you'll encounter an error like this:
Target#apply() call cannot use `value X` defined within the T{...} block
This error alone introduces a lot of questions if you're not familiar with the internals of Mill's tasks. Below are a collection of things that have helped me better understand them.
- Build Tools as Pure Functional Programs, A blog post by Li Haoyi, the creator of Mill.
- Static dependency graph and Applicative tasks, Part of the Mill documentation
Below is some insight from Olivier Mélois on the topic taken from the Typelevel Discord.
I don't have much video to share, but I'll try to give you an answer : in Scala, we are used to handling sequential logic in various contexts, because the language offers a pluggable syntax for it, that allows to easily clean up a gigantic amount of boilerplate heavy code: for-comprehensions. The standard library does not provide any abstraction for this pluggable mechanism, it just works for any construct that carries map/flatmap methods. This methods combined happen to be structurally capture to this construct called Monad (which cats and other libraries happen to have codified)
Understanding the concept of Monad is basically a rite of passage for everyone who learn semi-seriously the language, but in short, Monads are just an abstraction to sequentiality : ie you can express sequential computation with them, where you'll perform an operation, get the result, use the result in another operation, and so on. Pretty simple stuff, because most software logic happens to be sequential.
Now the thing is, though Monads are great, as they can let you reason the same way about different stuff that happen in massively different contexts (synchronous, asynchronous, error-carrying, etc), they are actually too powerful for a number of usecases, including "predictable computations". For instance, if you have
for {
x <- f
y <- if (x < 0) g else h
} yield y
you don't know whether g or h will be run UNTIL you have executed f. This means that you cannot easily capture a static "plan" for your Monadic computation that would predict a graph of calls, because the capability of monads to express sequences of calls make the list of calls in question inherently dynamic.
In the context of a build tool like mill, that tries to provide an agressive caching mechanism, this is a problematic, because caching requires being able to predict the structure of the computation
Thankfully, Monad is not the only abstraction that allow for composing computations. You also have Applicative, the difference being that Applicative does not allow for using the result of a computation in a subsequent computation. You can only compose computations horizontally, and map their results. In cats, this happens most of the time via the mapN operator, which mills calls zip
def fa: F[Int]
def fb: F[Int]
(fa, fb).mapN(_ + _)
Now the thing is, Scala doesn't have syntactic sugar for applicatives, like it does for monads, and these mapN calls can become verbose quickly as the bodies of mapN increase in size / complexity :
// see the repetition between `fa, fb, fc` and `a, b, c`
(fa, fb, fc).mapN((a: Int, b: Int, c:Int) => doSomething(a, b, c))
In order to solve this, people have built macros in userland, that offer applicative composition with a UX that is reminiscent of async/await. In mill, the macro is called T for task or target
// the duplication is eliminated in favour of a synthetised `apply` method which is eliminated at macro-expansion time
T { doSomething(fa(), fb(), fc())}