Typescript Module Configuration

Common Configuration Overrides

build.mill (download, browse)
package build

import mill._
import mill.javascriptlib._

object foo extends TypeScriptModule {

  def customSource = Task {
    Seq(PathRef(millSourcePath / "custom-src" / "foo2.ts"))
  }

  def allSources = super.allSources() ++ customSource()

  def resources = super.resources() ++ Seq(PathRef(millSourcePath / "custom-resources"))

  def generatedSources = Task {
    for (name <- Seq("A", "B", "C")) os.write(
      Task.dest / s"foo-$name.ts",
      s"""export default class Foo$name {
  static value: string = "Hello $name"
}
      """.stripMargin
    )

    Seq(PathRef(Task.dest))
  }

  def forkEnv = super.forkEnv() + ("MY_CUSTOM_ENV" -> "my-env-value")

  def mainFileName = s"foo2.ts"

  def mainFilePath = compile()._2.path / "custom-src" / mainFileName()

}

This example demonstrates usage of common configs

Note the use of millSourcePath, Task.dest, and PathRef when preforming various filesystem operations:

  1. millSourcePath: Base path of the module. For the root module, it’s the repo root. For inner modules, it’s the module path (e.g., foo/bar/qux for foo.bar.qux). Can be overridden if needed.

  2. Task.dest: Destination folder in the out/ folder for task output. Prevents filesystem conflicts and serves as temporary storage or output for tasks.

  3. PathRef: Represents the contents of a file or folder, not just its path, ensuring downstream tasks properly invalidate when contents change.

Typical Usage is given below:

> mill foo.run
hello2
Hello A
Hello B
Hello C
my-env-value
MyResource: My Resource Contents

> mill foo.bundle
Build succeeded!

> MY_CUSTOM_ENV=my-env-value node out/foo/bundle.dest/bundle.js
hello2
Hello A
Hello B
Hello C
my-env-value
MyResource: My Resource Contents
MyOtherResource: My Other Resource Contents

Custom Tasks

build.mill (download, browse)
package build

import mill._, javascriptlib._

object foo extends TypeScriptModule {

  /** Total number of lines in module source files */
  def lineCount = Task {
    allSources().map(f => os.read.lines(f.path).size).sum
  }

  def generatedSources = Task {
    os.write(
      Task.dest / "bar.ts",
      s"""export default class Bar {
  static value: number = 123
}
      """.stripMargin
    )

    Seq(PathRef(Task.dest))
  }

  def forkEnv = super.forkEnv() + ("LINE_COUNT" -> lineCount().toString)

  def printLineCount() = Task.Command { println(lineCount()) }

}

The above build defines the customizations to the Mill task graph shown below, with the boxes representing tasks defined or overriden above and the un-boxed labels representing existing Mill tasks:

G generatedSources generatedSources ... ... generatedSources->... run run ...->run sources sources lineCount lineCount sources->lineCount forkEnv forkEnv lineCount->forkEnv printLineCount printLineCount lineCount->printLineCount forkEnv->...

Mill lets you define new cached Tasks using the Task {…​} syntax, depending on existing Tasks e.g. foo.sources via the foo.sources() syntax to extract their current value, as shown in lineCount above. The return-type of a Task has to be JSON-serializable (using uPickle, one of Mill’s Bundled Libraries) and the Task is cached when first run until its inputs change (in this case, if someone edits the foo.sources files which live in foo/src). Cached Tasks cannot take parameters.

Note that depending on a task requires use of parentheses after the task name, e.g. pythonDeps(), sources() and lineCount(). This converts the task of type T[V] into a value of type V you can make use in your task implementation.

> mill foo.run "Hello World!"
Bar.value: 123
text: Hello World!
Line count: 13

Overriding Tasks

build.mill (download, browse)
package build

import mill._
import mill.javascriptlib._

object foo extends TypeScriptModule {
  def sources = Task {
    val srcPath = Task.dest / "src"
    val filePath = srcPath / "foo.ts"

    os.makeDir.all(srcPath)

    os.write.over(
      filePath,
      """(function () {
  console.log("Hello World!")
})()
      """.stripMargin
    )

    PathRef(Task.dest)
  }

  def compile = Task {
    println("Compiling...")
    os.copy(sources().path, super.compile()._2.path, mergeFolders = true)
    super.compile()
  }

  def run(args: mill.define.Args) = Task.Command {
    println("Running... " + args.value.mkString(" "))
    super.run(args)()
  }

}

You can re-define tasks to override them, and use super if you want to refer to the originally defined task. The above example shows how to override compile and run to add additional logging messages, and we override sources which was Task.Sources for the src/ folder with a plain T{…​} task that generates the necessary source files on-the-fly.

that this example replaces your src/ folder with the generated sources, as we are overriding the def sources task. If you want to add generated sources, you can either override generatedSources, or you can override sources and use super to include the original source folder with super:
> mill foo.run "added tags"
Compiling...
Hello World!
Running... added tags

Compilation & Execution Flags

build.mill (download, browse)
package build

import mill._
import mill.javascriptlib._

object foo extends TypeScriptModule {
  def compilerOptions =
    super.compilerOptions() + (
      "skipLibCheck" -> ujson.True,
      "module" -> ujson.Str("commonjs"),
      "target" -> ujson.Str("es6")
    )

  def executionFlags =
    Map(
      "inspect" -> "",
      "max-old-space-size" -> "4096"
    )

}

This example demonstrates defining typescript compiler options and node.js execution flags

> mill foo.run
Debugger listening on ws://...
...
Foo:
Hello World!

Filesystem Resources

build.mill (download, browse)
package build

import mill._, javascriptlib._
import ujson._

object foo extends TypeScriptModule {
  object test extends TypeScriptTests with TestModule.Jest {
    def otherFiles = T.source(millSourcePath / "other-files")

    def forkEnv = super.forkEnv() + ("OTHER_FILES_DIR" -> otherFiles().path.toString)
  }
}
> mill foo.run
Hello World Resource File

> mill foo.test
PASS .../foo.test.ts
...
Test Suites:...1 passed, 1 total...
Tests:...3 passed, 3 total...
...

> mill foo.bundle
Build succeeded!

> mv out/foo/bundle.dest ./bundle.dest && rm -rf out/

> node ./bundle.dest/bundle.js
Hello World Resource File

This section discusses how tests can depend on resources locally on disk. Mill provides two ways to do this: via ts-config paths, and via the resource folder which is made available as the environment variable MILL_TEST_RESOURCE_DIR;

  • The ts-config paths resources are useful when you want to fetch individual files, and are bundled with the application by the .bundle step when bundling code for deployment. But they do not allow you to list folders or perform other filesystem operations.

  • The resource folder, available via MILL_TEST_RESOURCE_DIR, gives you access to the folder path of the resources on disk. This is useful in allowing you to list and otherwise manipulate the filesystem, which you cannot do with tsconfig-paths resources. However, the MILL_TEST_RESOURCE_DIR only exists when running tests using Mill, and is not available when executing applications packaged for deployment via .bundle

  • Apart from resources/, you can provide additional folders to your test suite by defining a Task.Source (otherFiles above) and passing it to forkEnv. This provide the folder path as an environment variable that the test can make use of

Example application code demonstrating the techniques above can be seen below:

foo/resources/file.txt (browse)
Hello World Resource File
foo/test/resources/test-file-a.txt (browse)
Test Hello World Resource File A
foo/test/resources/test-file-b.txt (browse)
Test Hello World Resource File B
foo/test/other-files/other-file.txt (browse)
Other Hello World File

Note that tests require that you pass in any files that they depend on explicitly. This is necessary so that Mill knows when a test needs to be re-run and when a previous result can be cached. This also ensures that tests reading and writing to the current working directory do not accidentally interfere with each others files, especially when running in parallel.

Mill runs test processes in a sandbox/ folder, not in your project root folder, to prevent you from accidentally accessing files without explicitly passing them. Thus you cannot just read resources off disk via with fs.readFile("foo/resources/test-file-a.txt", {…​}) as file. If you have legacy tests that need to run in the project root folder to work, you can configure your test suite with def testSandboxWorkingDir = false to disable the sandbox and make the tests run in the project root.

Bundling Configuration

build.mill (download, browse)
package build

import mill._
import mill.javascriptlib._

object foo extends TypeScriptModule {
  def bundleFlags =
    Map(
      "platform" -> ujson.Str("node"),
      "entryPoints" -> ujson.Arr(mainFilePath().toString),
      "bundle" -> ujson.Bool(true),
      "minify" -> ujson.Bool(true)
    )

}

The above build demonstrates passing bundle flags After generating your bundle with mill foo.bundle you’ll find by running your out/foo/bundle.dest/bundle.js you’ll get the programmed output and usage is based on project logic like which args to include with command.

> mill foo.bundle
Build succeeded!

> node out/foo/bundle.dest/bundle.js
Hello World!