TypeScript SDK
The TypeScript SDK module still uses the previous beta SDK interface. It needs an update before the current dagger module init and module client commands can use it. The module runtime is unchanged.
The TypeScript SDK lets you write Dagger modules in TypeScript. You define an
@object() class with @func() methods, and Dagger exposes those methods as
API functions that anyone can call from the CLI, from another module, or over
the API. Inside your functions you orchestrate containers, files, services, and
secrets through a generated, fully-typed client.
This page assumes you already understand the Dagger module model. If you don't, read these first. This page stays light on platform concepts and links back where relevant:
- SDKs overview covers what a module is and how it runs
- Types covers how language types map to the Dagger API
- Generating code covers how generated files are reviewed and applied
The TypeScript SDK is itself a Dagger module,
dagger.io/sdk/typescript. The CLI module commands use an installed SDK
for source and client generation.
Create a module
Run these commands from inside a Git repository. That's where the new module is created.
Install the TypeScript SDK into your workspace, then use the module initialization command:
dagger module install dagger.io/sdk/typescript
dagger module init typescript --name=my-module
dagger module init returns a changeset describing the files to
create, which Dagger shows you to review before anything is written to disk.
dagger module init arguments (SDK-specific options require a compatible SDK):
| Argument | Required | Description |
|---|---|---|
--name | no | Module name. Without --name or --path, inferred as <project>-dev and installed as the workspace entrypoint. |
--path | no | Where to create the module. Defaults to <dagger.toml directory>/.dagger/modules/<name>. |
By default the module is created beside the active dagger.toml:
<dagger.toml directory>/.dagger/modules/my-module
Pass --path to choose a different location. A custom path is registered for
generation but is not installed; use dagger module install <path> to install it.
File layout
Once created and generated, a Node module looks like this:
my-module/
├── dagger-module.toml # module config
├── package.json # npm/yarn/pnpm package definition
├── tsconfig.json # TypeScript config with the @dagger.io/dagger path alias
├── sdk/ # generated TypeScript bindings (the typed client)
└── src/
└── index.ts # your module code
dagger module init runs generation as part of initialization. Source files and generated bindings are part of the same changeset.
dagger-module.toml declares the SDK source:
name = "my-module"
engineVersion = "v1.0.0-beta.14"
[runtime]
source = "typescript"
tsconfig.json wires the @dagger.io/dagger import to the local generated SDK
so editors resolve types without a separate install:
{
"compilerOptions": {
"target": "ES2022",
"moduleResolution": "Node",
"experimentalDecorators": true,
"strict": true,
"skipLibCheck": true,
"paths": {
"@dagger.io/dagger": ["./sdk/index.ts"],
"@dagger.io/dagger/telemetry": ["./sdk/telemetry.ts"]
}
}
}
package.json declares the package as an ES module:
{
"type": "module"
}
Define objects and functions
A module is a class decorated with @object(). Methods decorated with @func()
become Dagger API functions. The first @object() class is the module's main
object; its name in the API is derived from the module name.
import { dag, Container, Directory, object, func } from "@dagger.io/dagger"
@object()
class MyModule {
/**
* Build and publish a container image, returning the published ref
*/
@func()
async build(src: Directory): Promise<string> {
const builder = dag
.container()
.from("golang:latest")
.withDirectory("/src", src)
.withWorkdir("/src")
.withEnvVariable("CGO_ENABLED", "0")
.withExec(["go", "build", "-o", "myapp"])
const prodImage = dag
.container()
.from("alpine")
.withFile("/bin/myapp", builder.file("/src/myapp"))
.withEntrypoint(["/bin/myapp"])
return await prodImage.publish("ttl.sh/myapp:latest")
}
}
Call it like any other module:
dagger -m ./.dagger/modules/my-module api call build --src=.
Key points:
dagis the generated Dagger client. Every core type (Container,Directory,File,Service,Secret, …) is imported from@dagger.io/dagger.- The Dagger API is lazy and immutable. Each builder method returns a new value
and nothing executes until you
awaita leaf operation (publish,stdout,sync,entries, …). You can return an unresolvedContainer/Directoryand the caller decides when to evaluate it. - A
@func()may beasyncand return aPromise<T>, or return a lazy Dagger type directly.
The constructor
A class constructor becomes the module's constructor. Its parameters become arguments that callers (or workspace config) can set when they reference the module. Store them as fields so other functions can use them.
import { dag, Directory, object, func } from "@dagger.io/dagger"
@object()
class MyModule {
source: Directory
nodeVersion: string
constructor(source: Directory, nodeVersion = "20") {
this.source = source
this.nodeVersion = nodeVersion
}
@func()
build(): Container {
return dag
.container()
.from(`node:${this.nodeVersion}`)
.withDirectory("/app", this.source)
.withWorkdir("/app")
.withExec(["npm", "ci"])
.withExec(["npm", "run", "build"])
}
}
A field decorated with @func() is exposed as a readable API field; an
undecorated field is private internal state.
Workspace inputs
To read the user's project files, give your @object() constructor a
Workspace parameter. Dagger auto-populates it from the current workspace.
The caller passes nothing, and nothing is uploaded up front. The module then
reads project content lazily, on demand, so only the paths you actually
touch are loaded (and become part of the cache key).
import { dag, Container, Directory, Workspace, object, func } from "@dagger.io/dagger"
@object()
class MyModule {
source: Directory
// ws is auto-populated from the current workspace; the caller passes nothing.
constructor(ws: Workspace) {
// Read the workspace root as the module's source directory.
this.source = ws.directory("/")
}
@func()
build(): Container {
return dag
.container()
.from("node:20")
.withDirectory("/app", this.source)
.withWorkdir("/app")
.withExec(["npm", "ci"])
.withExec(["npm", "run", "build"])
}
}
The @object() constructor declares the Workspace as a plain typed parameter
with no decorator. Dagger fills it from the current workspace; the caller passes
nothing. The Workspace type is imported from @dagger.io/dagger.
A Workspace exposes lazy accessors for project content:
-
ws.directory(path, opts?)returns aDirectoryforpath. Takes an options object withexclude,include, andgitignore. Useexcludeaggressively to keep the cache key tight:this.source = ws.directory("/", { exclude: ["node_modules", ".git", "dist"] }) -
ws.file(path)returns aFileforpath.const pkg = ws.file("package.json") -
ws.findUp(name, opts?)walks up from a start path (opts.from) within the workspace looking forname. It returns the absolute workspace path, or null if not found. The search stops at the workspace root.const modRoot = await ws.findUp("package.json")
Path resolution. Relative paths (e.g. "src", "package.json") resolve
from the workspace cwd; absolute paths (e.g. "/src", "/go.mod") resolve
from the workspace root.
Because these accessors are lazy, reading a Directory or File from the
workspace does not upload anything until a downstream operation actually
evaluates it. Pull only the paths you need, with tight exclude patterns, to
minimize cache invalidation.
Arguments and return values
Function parameters become Dagger function arguments. TypeScript types map onto the Dagger type system:
| TypeScript | Dagger API | Notes |
|---|---|---|
string | String | |
number | Int / Float | |
boolean | Boolean | |
string[], T[] | list | |
Directory, File, Container, Service, Secret, Port, … | core types | imported from @dagger.io/dagger |
void / Promise<void> | Void | common for checks and side-effecting functions |
a custom @object() class | object | for returning multiple related values |
a TypeScript enum | enum | restricts to a set of values |
Documenting arguments and functions
Use JSDoc comments. The comment above a method is the function description; the
comment above a parameter is the argument description. Both appear in
dagger api functions and dagger api call --help.
@object()
class MyModule {
/**
* Query the GitHub API
*/
@func()
async githubApi(
/**
* GitHub API token
*/
token: Secret,
): Promise<string> {
return await dag
.container()
.from("alpine:3.17")
.withSecretVariable("GITHUB_API_TOKEN", token)
.withExec(["apk", "add", "curl"])
.withExec([
"sh",
"-c",
`curl "https://api.github.com/repos/dagger/dagger/issues" --header "Authorization: Bearer $GITHUB_API_TOKEN"`,
])
.stdout()
}
}
Defaults and optional arguments
A TypeScript default value makes the argument optional:
@func()
greet(name = "world"): string {
return `Hello, ${name}!`
}
dagger api call greet # Hello, world!
dagger api call greet --name=Sam # Hello, Sam!
Nullability
An optional parameter (name?: string) maps to a nullable argument that may be
omitted. A required parameter without a default must always be provided.
@func()
maybeGreet(name?: string): string {
return name ? `Hello, ${name}!` : "Hello!"
}
Enums
A TypeScript enum restricts an argument to a fixed set of values. Document
members with JSDoc.
import { func, object } from "@dagger.io/dagger"
/**
* Severity levels for a scan
*/
export enum Severity {
Low = "LOW",
Medium = "MEDIUM",
High = "HIGH",
Critical = "CRITICAL",
}
@object()
export class Security {
@func()
describe(severity: Severity): string {
return `scanning at severity ${severity}`
}
}
Passing an invalid value produces an error listing the valid choices.
Custom object types
To return multiple related values, define another @object() class and expose
its fields with @func():
import { dag, File, object, func } from "@dagger.io/dagger"
@object()
class BuildResult {
@func()
binary: File
@func()
version: string
constructor(binary: File, version: string) {
this.binary = binary
this.version = version
}
}
@object()
class MyModule {
@func()
build(): BuildResult {
const bin = dag
.container()
.from("golang:1.22")
.withExec(["sh", "-c", "echo built > /out/app"])
.file("/out/app")
return new BuildResult(bin, "1.0.0")
}
}
Dagger prefixes custom type names in the schema (e.g. MyModuleBuildResult) to
avoid collisions when multiple modules are loaded together.
Interfaces
Interfaces let your module accept arbitrary objects from other modules without
depending on their concrete types. Declare a plain TypeScript interface whose
members are the functions you need. Write them as fields with function types
that return Promises (every call is remote). Any object that provides matching
functions can be passed in:
import { dag, object, func } from "@dagger.io/dagger"
export interface Duck {
quack: () => Promise<string>
}
@object()
export class MyModule {
// Accept any object that satisfies the Duck interface.
@func()
async quackIt(duck: Duck): Promise<string> {
return await duck.quack()
}
// A function can also return an interface value.
@func()
getDuck(): Duck {
return dag.mallard()
}
}
Methods that take arguments are typed the same way, e.g.
withName: (name: string) => Promise<Duck>. Any object whose functions match
the interface, including objects returned by other modules, can be passed where
the interface is expected.
Core Dagger types
The generated client exposes the full Dagger API. The patterns below are the ones you'll reach for most.
Containers and files
@func()
test(source: Directory): Container {
return dag
.container()
.from("node:20")
.withDirectory("/app", source)
.withWorkdir("/app")
.withMountedCache("/app/node_modules", dag.cacheVolume("node-modules"))
.withExec(["npm", "ci"])
.withExec(["npm", "test"])
}
withMountedCache + dag.cacheVolume("name") give you a persistent,
content-addressed cache keyed by name. Every builder operation is layer-cached
automatically by its inputs.
Secrets
Accept secrets as the Secret type, never as a plain string. Dagger scrubs
secret values from logs, caches, and crash reports.
@func()
async deploy(token: Secret): Promise<string> {
return await dag
.container()
.from("alpine")
.withSecretVariable("DEPLOY_TOKEN", token)
.withExec(["sh", "-c", "deploy --token=$DEPLOY_TOKEN"])
.stdout()
}
Callers supply secrets through providers:
dagger api call deploy --token=env:DEPLOY_TOKEN
dagger api call deploy --token=file:./token.txt
dagger api call deploy --token=cmd:"gh auth token"
dagger api call deploy --token=op://vault/item/field
Services
@func()
integrationTest(source: Directory): Container {
const db = dag
.container()
.from("postgres:16")
.withEnvVariable("POSTGRES_PASSWORD", "test")
.withExposedPort(5432)
.asService()
return dag
.container()
.from("node:20")
.withDirectory("/app", source)
.withWorkdir("/app")
.withServiceBinding("db", db)
.withEnvVariable("DATABASE_URL", "postgres://postgres:test@db:5432/test")
.withExec(["npm", "run", "test:integration"])
}
Services are content-addressed. The same definition always resolves to the same hostname, so there are no port conflicts.
Concurrency
Because the API is lazy, you can start independent pipelines and await them
together with Promise.all:
@func()
async ci(source: Directory): Promise<void> {
await Promise.all([
this.lint(source).sync(),
this.test(source).sync(),
])
}
Module dependencies
Manage module clients from the module directory:
cd .dagger/modules/my-module
dagger module client list --sdk=typescript
dagger module client add dagger.io/go --sdk=typescript
dagger module client update --sdk=typescript
dagger module client rm dagger.io/go --sdk=typescript
These commands require the updated TypeScript SDK interface. Client add and remove commands update targets in dagger.toml. Client update refreshes dagger.lock. All three run generation. Installed module names are not valid client targets.
A module reference is [proto://]host/repo[/subpath][@version], e.g.
github.com/shykes/daggerverse/hello@v0.3.0. Local paths (./path/to/module)
are also supported. After adding a dependency, dagger-module.toml gains a
dependencies entry:
name = "my-module"
engineVersion = "v1.0.0-beta.14"
[runtime]
source = "typescript"
[[dependencies]]
name = "go"
source = "dagger.io/go"
Once a dependency is added and bindings are regenerated, call it through dag.
The dependency's functions are accessible by its name, and arguments are passed
as an options object:
import { dag, Directory, object, func } from "@dagger.io/dagger"
@object()
class MyModule {
@func()
example(buildSrc: Directory, buildArgs: string[]): Directory {
return dag.go().build({ source: buildSrc, args: buildArgs }).terminal()
}
}
Regenerate bindings
The sdk/ directory holds the generated typed client. Regenerate it whenever
you change the module API or engine version. Use dagger generate, which returns
a changeset:
dagger generate
dagger generate runs registered SDK scopes and author-defined generators. SDK binding generation requires a compatible TypeScript SDK module.
Commit vs. generate
Commit generated SDK files so consumers and CI can build the module. Run dagger generate, inspect the changeset, and commit the result with the change that required it.
Engine version
dagger-module.toml's engineVersion records the engine your module targets. Edit that field to change the requirement, then regenerate bindings with a compatible SDK.
Checks, generators, directives, and ignore
A useful, reusable module provides at least one first-class function type:
a check (run by dagger check), a generator (run by dagger generate),
or a service (started by dagger up). Each is a regular @func() annotated
with an additional decorator.
Checks
A check is a function that validates something and passes or fails by exit code.
Mark it with @check(). dagger check discovers and runs every check in the
module.
import { dag, Directory, Workspace, object, func, check } from "@dagger.io/dagger"
@object()
class MyModule {
source: Directory
constructor(ws: Workspace) {
this.source = ws.directory("/")
}
/**
* Lint the code
*/
@func()
@check()
async lint(): Promise<void> {
await dag
.container()
.from("node:20")
.withDirectory("/app", this.source)
.withWorkdir("/app")
.withExec(["npm", "ci"])
.withExec(["npm", "run", "lint"])
.sync()
}
/**
* Run unit tests
*/
@func()
@check()
async test(): Promise<void> {
await dag
.container()
.from("node:20")
.withDirectory("/app", this.source)
.withWorkdir("/app")
.withExec(["npm", "ci"])
.withExec(["npm", "test"])
.sync()
}
}
dagger check
A check passes if it returns without throwing; it fails if any withExec
returns a non-zero exit code (which surfaces as a thrown error when you sync).
Generators
A generator produces a changeset, a diff between the
current source and generated output. Mark it with @generate() and return a
Changeset. dagger generate runs every generator and presents the combined
changeset for review.
import { dag, Directory, Workspace, Changeset, object, func, generate } from "@dagger.io/dagger"
@object()
class MyModule {
source: Directory
constructor(ws: Workspace) {
this.source = ws.directory("/")
}
/**
* Generate API client code
*/
@func()
@generate()
generateApi(): Changeset {
return dag
.container()
.from("node:20")
.withDirectory("/app", this.source)
.withWorkdir("/app")
.withExec(["npm", "ci"])
.withExec(["npm", "run", "generate:api"])
.directory("/app")
.changes(this.source)
}
}
.changes(this.source) computes the diff against the original source.
This author-written @generate() function differs from SDK binding generation
(covered in Regenerate bindings). Your functions produce
project-specific output (API clients, config, scaffolding) and are run by
dagger generate.
Services
A service is a long-running container, such as a database, web server, or other
dependency. Mark a @func() with @up() and return a Service. dagger up
starts every service in the module. See Services.
import { Service, dag, object, func, up } from "@dagger.io/dagger"
@object()
class MyModule {
/**
* A web server service
*/
@func()
@up()
web(): Service {
return dag.container().from("nginx:alpine").withExposedPort(80).asService()
}
/**
* A redis service
*/
@func()
@up()
redis(): Service {
return dag.container().from("redis:alpine").withExposedPort(6379).asService()
}
}
dagger up
The @up() decorator goes below @func(), and the function returns a Service
built from a container with .asService().
Cache directive
Control function caching with the @func({ cache }) option:
// Persistent caching for 10 minutes (useful for external data)
@func({ cache: "10m" })
async latestRelease(): Promise<string> { /* ... */ }
// Cache only for the current session
@func({ cache: "session" })
async sessionId(): Promise<string> { /* ... */ }
// Never cache, re-execute every call
@func({ cache: "never" })
async currentTime(): Promise<string> { /* ... */ }
A cache miss still benefits from layer caching for operations inside the
function. "never" forces the function to re-run but does not disable layer
caching for its operations.
Ignore
When you read a directory from the workspace, filter it with the exclude
option on ws.directory(path, { exclude }) (see
Workspace inputs). Push file access to the leaves: read only
the paths you need, with tight exclude patterns, to minimize cache
invalidation.
this.source = ws.directory("/", { exclude: ["node_modules", ".git", "dist"] })
Testing TypeScript modules
The primary way to test a module is to call its functions and run its checks:
# Smoke test. Does it build?
dagger -m ./.dagger/modules/my-module api call build --src=.
# Run all checks
dagger check
# Run generators and confirm there's no drift
dagger check --generate
For richer behavioral tests, write a separate test module (in TypeScript or any SDK) that adds your module as a dependency and asserts on its outputs. Because services and containers are content-addressed and reproducible, integration tests against ephemeral databases or HTTP services are deterministic.
In CI
Run dagger check from CI to execute every check the module defines:
name: ci
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dagger/dagger-for-github@v6
with:
version: "v1.0.0-beta.14"
verb: check
If your module keeps generated sdk/ files in git, also gate on drift by
running dagger check --generate.
IDE and package-manager setup
Runtimes and package managers
TypeScript modules can use Node, Bun, or Deno. For
Node, the SDK works with npm, yarn, and pnpm. The package manager is detected
from your lockfile (package-lock.json, yarn.lock, or pnpm-lock.yaml).
Commit the lockfile so builds are reproducible. Bun and Deno manage dependencies
through their own native tooling; the module code and decorators are identical
across runtimes. Only dependency installation and lockfiles differ.
Editor type resolution
Because tsconfig.json maps @dagger.io/dagger to the local ./sdk/index.ts,
most editors resolve Dagger types and offer completion without a separate
install:
"experimentalDecorators": true,
"paths": {
"@dagger.io/dagger": ["./sdk/index.ts"],
"@dagger.io/dagger/telemetry": ["./sdk/telemetry.ts"]
}
experimentalDecorators is required for @object(), @func(), @check(),
@generate(), and @argument() to work.
Source maps
When working across inter-dependent modules, the SDK attaches source maps to
generated code (line comments of the form ./path/to/filename:line), so you can
click through to a function's declaration in another module. Most editors support
this natively or via a plugin such as the
Open file plugin
for VS Code.
Packaging and release
A TypeScript module is published simply by pushing it to a git repository. There is no separate publish step. Consumers reference it by its git address and version (a tag, branch, or commit):
# In a consumer module's workspace
dagger module install github.com/you/my-module@v1.0.0
This installs the module in the consumer's dagger.toml. Use dagger module client add <ref> --sdk=typescript to call it from module code. Tag releases (e.g.
v1.0.0) so consumers can pin a stable version. Keep generated sdk/ files
committed so consumers don't have to regenerate before using your module.
Recommended release checklist:
dagger checkpasses.dagger check --generateproduces no drift.engineVersionindagger-module.tomlmatches the engine you support.- Tag and push:
git tag vX.Y.Z && git push --tags.
Troubleshooting
Decorators not recognized / runtime errors about metadata. Ensure
experimentalDecorators is true in tsconfig.json and that your @object()
class is exported/defined as shown. The first @object() class is the module's
main object.
Calling a dependency fails with "unknown function" or missing types. Add the
client with dagger module client add <ref> --sdk=typescript from the module directory and
apply the changeset. The generated sdk/ client must include the dependency
before dag.<dep>() resolves.
dagger check finds no checks. Confirm the functions are decorated with both
@func() and @check() and that the class is the module's @object().
CI build fails on missing sdk/. Either commit the generated sdk/
directory, or run dagger generate with a compatible SDK as a build step before
calling the module.
Engine version mismatch warnings. Align dagger-module.toml's engineVersion
with your engine, then regenerate bindings with a compatible SDK.