Example: Typescript Support
This section walks through the process of adding support for a new programming
language to Mill. We will be adding a small trait TypeScriptModule with the
ability to resolve dependencies, typecheck local code, and optimize a final
bundle.
The TypeScript integration here is not intended for production usage, but is instead meant for illustration purposes of the techniques typically used in implementing language toolchains.
Basic TypeScript Build Pipeline
This example demonstrates basic integration of Typescript compilation into a Mill build to compile Node.js apps. Mill does not come bundled with a Typescript integration, so here we begin setting one up from first principles using the NPM command line tool and package repository
Installing TypeScript
First, we need to use the npm CLI tool to install typescript and the @types/node
library necessary for accessing Node.js APIs:
package build
import mill.*
def npmInstall = Task {
os.call(("npm", "install", "--save-dev", "typescript@5.6.3", "@types/node@22.7.8"))
PathRef(Task.dest)
}
The npmInstall task runs npm install to install TypeScript locally, following
the TypeScript installation instructions.
The os.call above by default runs inside the npmInstall task’s unique Task.dest
output directory due to task sandboxing. Note that we
use an explicit version on each of the modules to ensure the Task is reproducible.
We then return a PathRef to the Task.dest so downstream tasks can make use of it.
Note that as written, the npmInstall task will never invalidate unless you change its
code. This is what we should expect, since npmInstall has no upstream tasks it depends
on and the versions of typescript and @types/node are fully specified in the task.
This assumes that the npm package repository always returns the same artifacts for
the given name and version, which is a reasonable assumption for most package repositories.
Defining our sources
Next, we define the sources of our Typescript build using a
source task. Here sources refers to the
source folder, and the subsequent allSources walks that folder and picks up all
the individual typescript files within. This is a common pattern to give flexibility,
e.g. someone can later easily override allSources to add additional filtering
on exactly which files within the source root they wish to pick up.
def sources = Task.Source("src")
def allSources = Task {
os.walk(sources().path).filter(_.ext == "ts").map(PathRef(_))
}
Compilation
Next, we define our compile task. This is again a relatively straightforward subprocess
call invoking the typescript/bin/tsc executable within the node_modules folder from the
upstream npmInstall task, passing it the sources, --outDir, --types, and --typeRoots
Again we return a PathRef to the Task.dest folder we output the compiled JS files to
def compile = Task {
os.call(
(
npmInstall().path / "node_modules/typescript/bin/tsc",
allSources().map(_.path),
"--outDir",
Task.dest,
"--typeRoots",
npmInstall().path / "node_modules/@types"
)
)
PathRef(Task.dest)
}
At this point, we have a minimal working build, with a build graph that looks like this:
Given an input file below, we can run
mill compile and demonstrate it is installing typescript locally and using it to compile
the .ts files in out src/ folder:
interface User {
firstName: string
lastName: string
role: string
}
const user: User = {
firstName: process.argv[2],
lastName: process.argv[3],
role: "Professor",
}
console.log("Hello " + user.name + " " + user.lastName)
> ./mill compile
error: .../src/hello.ts(...): error ... Property 'name' does not exist on type...
> sed -i.bak 's/user.name/user.firstName/g' src/hello.ts
> ./mill compile
> cat out/compile.dest/hello.js # Output is stripped of types and converted to javascript
var user = {
firstName: process.argv[2],
lastName: process.argv[3],
role: "Professor",
};
console.log("Hello " + user.firstName + " " + user.lastName);
Running
The last step here is to allow the ability to run our compiled JavaScript file.
To do this, we need a mainFileName task to tell Mill which file should be used
as the program entrypoint, and a run command taking arguments that get used to
call node along with the main Javascript file:
def mainFileName = Task { "hello.js" }
def run(args: mill.api.Args) = Task.Command {
os.call(
("node", compile().path / mainFileName(), args.value),
stdout = os.Inherit
)
}
Note that we use stdout = os.Inherit since we want to display any output to
the user, rather than capturing it for use in our command.
> ./mill run James Bond
Hello James Bond
So that’s a minimal example of implementing a single TypeScript to JavaScript build
pipeline locally. Next, we will look at turning it into a TypeScriptModule that
can be re-used
Re-usable TypeScriptModule
In this example, we will explore how to take the one-off typescript build pipeline
we wrote above, and turn it into a re-usable TypeScriptModule.
To do this, we take all the code we wrote earlier and surround it with
trait TypeScriptModule extends Module wrapper:
package build
import mill.*
trait TypeScriptModule extends Module {
def npmInstall = Task {
os.call(("npm", "install", "--save-dev", "typescript@5.6.3", "@types/node@22.7.8"))
PathRef(Task.dest)
}
def sources = Task.Source("src")
def allSources = Task {
os.walk(sources().path).filter(_.ext == "ts").map(PathRef(_))
}
def compile = Task {
val tsc = npmInstall().path / "node_modules/typescript/bin/tsc"
val types = npmInstall().path / "node_modules/@types"
os.call((tsc, allSources().map(_.path), "--outDir", Task.dest, "--typeRoots", types))
PathRef(Task.dest)
}
def mainFileName = Task { s"${moduleDir.last}.js" }
def run(args: mill.api.Args) = Task.Command {
val mainFile = compile().path / mainFileName()
os.call(("node", mainFile, args.value), stdout = os.Inherit)
}
}
We can then instantiate the module three times. Module can be adjacent or nested, as shown belo:
object foo extends TypeScriptModule {
object bar extends TypeScriptModule
}
object qux extends TypeScriptModule
interface User { firstName: string }
const user: User = { firstName: process.argv[2] }
console.log("Hello " + user.firstName + " Foo")
interface User { firstName: string }
const user: User = { firstName: process.argv[2] }
console.log("Hello " + user.firstName + " Bar")
interface User { firstName: string }
const user: User = { firstName: process.argv[2] }
console.log("Hello " + user.firstName + " Qux")
And then invoke the .run method on each module from the command line:
> ./mill foo.run James
Hello James Foo
> ./mill foo.bar.run James
Hello James Bar
> ./mill qux.run James
Hello James Qux
At this point, we have multiple TypeScriptModules, with bar nested inside foo,
but they are each independent and do not depend on one another.
Next, we will look at how to wire them up using
moduleDeps.
TypeScriptModule moduleDeps
This example extends TypeScriptModule to support moduleDeps.
-
The
def compiletask is considerably fleshed out: rather than using command line flags, we generate atsconfig.jsonfile using theujson.Obj/ujson.ArrJSON factory methods from the bundled uPickle library. -
def compilenow returns twoPathRef`s: one containing the `.jsoutput to use indef run, and one containing the.d.tsoutput to use in downstreamcompiles. -
def moduleDepsis used to allow differentTypeScriptModules to depend on each other, and we useTask.traverseto combine the upstreamcompiledDefinitionsfor use incompile, andcompiledJavascriptfor use inrun
package build
import mill.*
trait TypeScriptModule extends Module {
def moduleDeps: Seq[TypeScriptModule] = Nil
def npmInstall = Task {
os.call(("npm", "install", "--save-dev", "typescript@5.6.3", "@types/node@22.7.8"))
PathRef(Task.dest)
}
def sources = Task.Source("src")
def allSources = Task { os.walk(sources().path).filter(_.ext == "ts").map(PathRef(_)) }
def compile: T[(PathRef, PathRef)] = Task {
val nodeTypes = npmInstall().path / "node_modules/@types"
val javascriptOut = Task.dest / "javascript"
val declarationsOut = Task.dest / "declarations"
val upstreamPaths =
for (((jsDir, dTsDir), mod) <- Task.traverse(moduleDeps)(_.compile)().zip(moduleDeps))
yield (mod.moduleDir.subRelativeTo(build.moduleDir).toString + "/*", dTsDir.path)
val allPaths = upstreamPaths ++ Seq("*" -> sources().path)
os.write(
Task.dest / "tsconfig.json",
ujson.Obj(
"compilerOptions" -> ujson.Obj(
"outDir" -> javascriptOut.toString,
"declaration" -> true,
"declarationDir" -> declarationsOut.toString,
"typeRoots" -> ujson.Arr(nodeTypes.toString),
"paths" -> ujson.Obj.from(allPaths.map { case (k, v) => (k, ujson.Arr(s"$v/*")) })
),
"files" -> allSources().map(_.path.toString)
)
)
os.call(npmInstall().path / "node_modules/typescript/bin/tsc")
(PathRef(javascriptOut), PathRef(declarationsOut))
}
def mainFileName = Task { s"${moduleDir.last}.js" }
def run(args: mill.api.Args) = Task.Command {
val upstream = Task.traverse(moduleDeps)(_.compile)().zip(moduleDeps)
for (((jsDir, tTsDir), mod) <- upstream) {
os.copy(jsDir.path, Task.dest / mod.moduleDir.subRelativeTo(build.moduleDir))
}
val mainFile = compile()._1.path / mainFileName()
os.call(
("node", mainFile, args.value),
stdout = os.Inherit,
env = Map("NODE_PATH" -> Seq(".", compile()._1.path).mkString(":"))
)
}
}
Note the use of Task.traverse(moduleDeps) in order to aggregate the compile
output of the upstream modules, which is necessary both to configure the tsc TypeScript
compiler in compile and also to set up the node working directory in run. This
is a common pattern when defining language modules, whose module-level dependencies need
to be translated into task-level dependencies
Again, we can instantiate TypeScriptModule three times, but now foo/src/foo.ts
and foo/bar/src/bar.ts export their APIs which are then imported in qux/src/qux.ts:
object foo extends TypeScriptModule {
object bar extends TypeScriptModule
}
object qux extends TypeScriptModule {
def moduleDeps = Seq(foo, foo.bar)
}
interface User {
firstName: string
lastName: string
role: string
}
export {User}
const defaultRole = "Professor"
export {defaultRole}
import {User} from "foo/foo.js"
import {defaultRole} from "foo/bar/bar.js"
const user: User = {
firstName: process.argv[2],
lastName: process.argv[3],
role: defaultRole,
}
console.log("Hello " + user.firstName + " " + user.lastName + " " + user.role)
We can then invoke the qux.run method on each module from the command line:
> ./mill qux.run James Bond
Hello James Bond Professor
The dependency graph of tasks now looks like this, with the output of
foo.compile and bar.compile now being fed into qux.compile (and
ultimately qux.run):
NPM dependencies and bundling
This example expands TypeScriptModule in two ways:
-
Previously,
def npmInstallwas hardcoded to installtypescriptand@types/node, because that was what was needed to compile Typescript against the builtin Node.js APIs. In this example, we add adef npmDepstask, that is aggregated usingTask.traverseintodef transitiveNpmDeps, that are then included in the body ofdef npmInstall. ThenpmInstalldestination folder in then used both indef compileto provide thetsccompiler and supporting installed type definitions, as well as indef runin order to provide the necessary files to thenoderuntime. -
We include
esbuild@0.24.0as part of ournpm install, for use in adef bundletask that uses it to callesbuildto bundle our 3TypeScriptModules into a singlebundle.jsfile. The logic shared betweendef runanddef bundlehas been extracted into adef prepareRuntask.
package build
import mill.*
trait TypeScriptModule extends Module {
def moduleDeps: Seq[TypeScriptModule] = Nil
def npmDeps: T[Seq[String]] = Task { Seq() }
def transitiveNpmDeps: T[Seq[String]] = Task {
Task.traverse(moduleDeps)(_.npmDeps)().flatten ++ npmDeps()
}
def npmInstall = Task {
os.call((
"npm",
"install",
"--save-dev",
"typescript@5.6.3",
"@types/node@22.7.8",
"esbuild@0.24.0",
transitiveNpmDeps()
))
PathRef(Task.dest)
}
def sources = Task.Source("src")
def allSources = Task { os.walk(sources().path).filter(_.ext == "ts").map(PathRef(_)) }
def compile: T[(PathRef, PathRef)] = Task {
val nodeTypes = npmInstall().path / "node_modules/@types"
val javascriptOut = Task.dest / "javascript"
val declarationsOut = Task.dest / "declarations"
val upstreamPaths =
for (((jsDir, dTsDir), mod) <- Task.traverse(moduleDeps)(_.compile)().zip(moduleDeps))
yield (mod.moduleDir.subRelativeTo(build.moduleDir).toString + "/*", dTsDir.path)
val allPaths = upstreamPaths ++ Seq("*" -> sources().path, "*" -> npmInstall().path)
os.write(
Task.dest / "tsconfig.json",
ujson.Obj(
"compilerOptions" -> ujson.Obj(
"outDir" -> javascriptOut.toString,
"declaration" -> true,
"declarationDir" -> declarationsOut.toString,
"typeRoots" -> ujson.Arr(nodeTypes.toString),
"paths" -> ujson.Obj.from(allPaths.map { case (k, v) => (k, ujson.Arr(s"$v/*")) })
),
"files" -> allSources().map(_.path.toString)
)
)
os.call((npmInstall().path / "node_modules/typescript/bin/tsc"))
(PathRef(javascriptOut), PathRef(declarationsOut))
}
def mainFileName = Task { s"${moduleDir.last}.js" }
def prepareRun = Task.Anon {
val upstream = Task.traverse(moduleDeps)(_.compile)().zip(moduleDeps)
for (((jsDir, tTsDir), mod) <- upstream) {
os.copy(jsDir.path, Task.dest / mod.moduleDir.subRelativeTo(build.moduleDir))
}
val mainFile = compile()._1.path / mainFileName()
val env = Map("NODE_PATH" -> Seq(".", compile()._1.path, npmInstall().path).mkString(":"))
(mainFile, env)
}
def run(args: mill.api.Args) = Task.Command {
val (mainFile, env) = prepareRun()
os.call(("node", mainFile, args.value), stdout = os.Inherit, env = env)
}
def bundle = Task {
val (mainFile, env) = prepareRun()
val esbuild = npmInstall().path / "node_modules/esbuild/bin/esbuild"
val bundle = Task.dest / "bundle.js"
os.call((esbuild, mainFile, "--bundle", s"--outfile=$bundle"), env = env)
PathRef(bundle)
}
}
object foo extends TypeScriptModule {
object bar extends TypeScriptModule {
def npmDeps = Seq("immutable@4.3.7")
}
}
object qux extends TypeScriptModule {
def moduleDeps = Seq(foo, foo.bar)
}
We can now not only invoke the qux.run to run the TypeScriptModule immediately
using node, we can also use qux.bundle to generate a bundle.js file we can run
standalone using node:
> ./mill qux.run James Bond prof
Hello James Bond Professor
> ./mill show qux.bundle
".../out/qux/bundle.dest/bundle.js"
> node out/qux/bundle.dest/bundle.js James Bond prof
Hello James Bond Professor
The final module tree and task graph is now as follows, with the
additional npmDeps tasks upstream and the bundle tasks downstream:
As mentioned earlier, the TypeScriptModule examples on this page are meant for
demo purposes: to show what it looks like to add support in Mill for a new
programming language toolchain. It would take significantly more work to flesh out
the feature set and performance of TypeScriptModule to be usable in a real world
build. But this should be enough to get you started working with Mill to add support
to any language you need: whether it’s TypeScript or some other language, most programming
language toolchains have similar concepts of compile, run, bundle, etc.
As mentioned, The PythonModule examples here demonstrate
how to add support for a new language toolchain in Mill.
A production-ready version would require more work to enhance features and performance.