Skip to content

Packages and Modules

Dovetail organizes code into packages and modules. Every source file declares its package, and you bring types and functions from other packages in with imports. The compiler enforces that package names match the directory structure and that dependencies form a directed acyclic graph—no circular imports.


Every Dovetail source file must start with a package declaration. This identifies which package the file belongs to:

package hello
function main() =
debug("Hello, World!")

For a library with a deeper structure:

package com.example.users
record User =
name: String
age: Int32

The package path uses dot notation. Each segment typically corresponds to a directory: com.example.users matches a path like com/example/users/ under the project’s source root.

The compiler enforces that the declared package matches the file’s location. If your project’s root package is com.example.myapp, then:

  • src/main.dove → package com.example.myapp
  • src/utils/helpers.dove → package com.example.myapp.utils

This keeps the codebase predictable: you can infer the package from the path and the path from the package.

Relationship to Projects and Dovetail.toml

Section titled “Relationship to Projects and Dovetail.toml”

When you use the dovetail CLI, each project in the workspace has a root package defined in Dovetail.toml:

compiler-version = "0.1.4"
[[project]]
name = "api"
root_package = "com.example.api"
packages = ["."]

All source files under that project’s src/ directory belong to packages under that root. For example, api/src/handlers.dove would declare package com.example.api, and api/src/auth/validate.dove would declare package com.example.api.auth.

Single-file compilation with dovetail does not use Dovetail.toml; you still declare a package at the top of the file (e.g. package myapp).

Instead of writing package and then an inline module Name = ..., you can make the entire file a module by starting with:

module package_path.ModuleName

or, for a generic type, module package_path.TypeName<T> (e.g. module standard.prelude.Array<T>). The rest of the file is the module body; the effective package is package_path. The kind of module (standalone vs module for a type) is determined by whether that name is an existing type in the package. Different files in a package may define different modules. A type and its associated same-name module may also be declared in separate files of that package. See Part 6: Type System — Modules for the two kinds of modules and what each may contain.


To use a type or function from another package, add an import after the package declaration. Imports come at the top of the file, one per line:

package api
import users.User
import users.Email
import users.validate
import http.Request
import http.Response
function handleGetUser(req: Request): Response =
let user = User.find(req.params.id) orReturn
Ok(user.toJson())

You import by qualified name: the package path followed by the declaration name. There are no wildcard imports (import users.* is not allowed), so it’s always clear where each name comes from.

If a name would clash with a local type or you prefer a shorter name, use an alias:

package api
import users.User as AppUser
import http.Request as HttpRequest
function handle(req: HttpRequest): Unit =
let u: AppUser = AppUser.fromRequest(req)
...

The alias is used everywhere in that file instead of the original name.

For packages with multiple segments, use the full path in the import:

package com.example.myapp
import com.example.core.types.Id
import com.example.core.utils.formatDate

The compiler resolves imports using the packages exposed by the current project and its dependencies (as specified in Dovetail.toml for workspace builds).

Modules and extension methods across packages

Section titled “Modules and extension methods across packages”

Same package: Instance methods on a type in the same package come from a module for that type (a module with the same name as the type, if the package defines one). You do not need to import the module to use instance members — they are available whenever the type is in scope. Static members and globals require the module to be in scope (same package or imported). For a generic type you must supply type arguments when accessing statics or globals: e.g. Array<Int32>.empty, Array<Int32>.defaultCapacity. See Modules.

Cross package: To add methods to a type from another package, use a named extension and import it explicitly:

json/encoders.dove
package json
import users.User
extension UserJson for User =
function toJson(self): Json = ...

Consumers must import the extension by name:

package api
import users.User
import json.UserJson
function serialize(u: User): Json =
u.toJson() // UserJson is in scope via import

Trait implementations for types from another package follow the orphan rule (see Part 8): you may only implement a trait if your package defines either the trait or the type. When the trait is in your package and the type is imported, the implementation is discovered when the trait is used; you don’t import the implementation itself.


Declarations can be restricted by visibility. This controls whether other packages (or other files in the same package) can see a type, function, or member.

Modifier Meaning
public Visible from any package that depends on this package.
internal Visible within the same package only. This is the default.
private Visible within the declaring file only.
protected Visible within the declaring class and its subclasses (see Part 9).

If you don’t write a modifier, the declaration is internal — visible to the rest of its own package and to nothing else. That makes each package’s public surface an explicit choice, which is what lets you use packages as architectural layers.

Use public when you want to expose a type or function to other packages:

package math
public function add(a: Int32, b: Int32): Int32 =
a + b
public record Point =
x: Int32
y: Int32

Without public, add and Point would only be visible inside the math package.

A leading private hides a declaration from other files. A private after a type’s name restricts operations on its values. These are separate choices:

package users
public newtype Email private = String
module Email =
public function parse(s: String): Result<Email, String> =
if s.contains("@") then
Ok(Email(s))
else
Error("Invalid email")
public function text(self): String = self.value

Other packages can import users.Email, call Email.parse, and use email.text(). Direct construction, .value, and unwrapping patterns require module Email in package users. Another function in users, a trait implementation, an extension, or a same-named module in another package has no such privilege.

For public record Account private = ... and public enum Status private = ..., callers can inspect fields and match patterns. Only their associated modules in the defining package can construct values; record with updates have the same restriction. Put the modifier after any type parameters and before any where clause. See private records, private enums, and private newtypes.

internal is the default, so most declarations are already package-scoped: visible to every file of their own package, invisible everywhere else. Write it explicitly when you want that intent to be obvious. Use private when a helper should not even escape the file it is declared in.


Packages must not depend on each other in a cycle. If package A imports from package B, then B must not import from A. The dependency graph must be a directed acyclic graph (DAG).

The compiler enforces this. You’ll get an error if you introduce a circular dependency.

  • Compilation order: The compiler processes packages in dependency order. Cycles would make that order undefined.
  • Clear boundaries: Acyclic dependencies encourage a clear layering of your code (e.g. core → domain → api → app).
  • Faster builds: The build system can schedule work along the DAG; cycles would complicate caching and parallelization.

In a workspace, dependencies are declared per project in Dovetail.toml:

compiler-version = "0.1.4"
[[project]]
name = "core"
root_package = "com.example.core"
packages = ["."]
[[project]]
name = "api"
root_package = "com.example.api"
depends = ["core"]
packages = ["."]
[[project]]
name = "myapp"
root_package = "com.example.myapp"
depends = ["core", "api"]
packages = ["."]

Here, core has no project dependencies; api depends on core; myapp depends on both. Packages under api can import from packages under core, and packages under myapp can import from both. The reverse is not allowed: core cannot depend on api or myapp.

If you discover a cycle, you usually need to:

  1. Extract shared code into a new package that both sides depend on (e.g. move common types into core).
  2. Invert the dependency (e.g. use callbacks or traits so that the lower layer doesn’t reference the higher layer by type).
  3. Restructure so that the dependency flows in one direction (e.g. merge two packages or split one).

Projects can use libraries from public or private Git repositories, including repositories containing several Dovetail projects. Declare each repository and revision once in Dovetail.toml, select the projects to make available, and use their names in a local project’s depends list.

The following example imports two projects from one repository. Replace the example URL and tag with those of your library:

compiler-version = "0.1.4"
[[dependencies]]
git = "https://github.com/acme/database.git"
tag = "v1.2.0"
projects = ["postgres", "sqlite"]
[[project]]
name = "api"
root_package = "com.example.api"
packages = ["."]
depends = ["postgres", "sqlite"]

Use [[dependencies]] with double brackets for each repository declaration. It has no user-assigned section name. The projects list selects project names from the repository’s Dovetail.toml; those names are available throughout the declaring manifest. Each local project chooses which ones it uses through depends, just as it does for local sibling projects.

The compiler resolves the complete transitive dependency tree. If postgres depends on another project in its own repository or on a project from another Git repository, Dovetail resolves that dependency using the library’s manifest. Your application does not need to repeat those declarations. Cycles remain errors, including cycles spanning repositories.

Every manifest must declare compiler-version. Run dovetail --version to find your binary’s version; the examples here assume 0.1.4. The workspace and every reachable dependency manifest must require that exact version. Build, check, run, and test reject a root version mismatch before fetching dependencies. Editor analysis performs the same compatibility checks.

The compiler version also pins the bundled prelude. Dependencies use that prelude, and cannot supply a conflicting replacement. A published tag without compiler-version cannot be used until its publisher adds the field in a new revision.

Choose at most one revision selector per repository declaration:

Field Selects
tag = "v1.2.0" A Git tag
branch = "1.2" A Git branch named 1.2
rev = "<commit-sha>" A specific Git commit

Omitting all three initially selects the repository’s default branch. Dovetail records the resolved full commit in Dovetail.lock; normal builds keep that commit even if the branch or tag moves upstream. These selectors are Git references, not semantic-version ranges.

For a repository with a manifest below its root, add manifest = "libraries/Dovetail.toml" to the repository declaration. The selected project names and their directories are resolved relative to that manifest.

Repository project names select dependencies; source imports use the library’s declared package names. For example, if the postgres project exposes a public Connection type in acme.postgres, your application imports it as:

package com.example.api
import acme.postgres.Connection

Neither the repository URL nor its revision appears in the import. The library still controls which declarations are public.

By default, an imported project keeps its original name. Use { project, alias } to choose another name in the consuming manifest. For example, add this top-level declaration alongside the first one to make an older revision available as postgres-1.1:

[[dependencies]]
git = "https://github.com/acme/database.git"
tag = "v1.1.0"
projects = [{ project = "postgres", alias = "postgres-1.1" }]

A local project can then select it with depends = ["postgres-1.1"]. Aliases are scoped to the manifest declaring them and must not collide with local project names or other imported aliases. They do not rename package FQNs or source imports; this is different from an import ... as ... alias, which renames a declaration within one source file.

Different local targets may use different revisions. However, two distinct providers of the same package FQN cannot coexist in one target’s dependency tree, including through transitive dependencies. Adding both Postgres revisions to one target produces an error with the conflicting dependency paths. A diamond that reaches the same resolved project twice is deduplicated.

A normal dovetail build fetches missing dependencies as needed. You can also manage them explicitly:

Terminal window
dovetail deps fetch
dovetail build --locked
dovetail check --locked --offline
dovetail deps update postgres
dovetail deps update
  • deps fetch materializes declared selections and their transitive dependencies, including selections not yet used by a local target.
  • --locked requires Dovetail.lock to remain unchanged. Use it after resolving dependencies to detect changes that would require a new lockfile.
  • --offline prevents network fetching. Dependencies must already be available locally.
  • deps update postgres updates the repository declaration containing that name or alias. All projects selected by that declaration move together, so in the first example both postgres and sqlite update.
  • deps update updates dependencies across the graph.

Updating re-resolves the declared Git selector. To switch from v1.2.0 to a newer release tag, edit tag in Dovetail.toml and run dovetail deps fetch or build again.

Commit Dovetail.lock and ignore .dovetail/. Dovetail shares source checkouts at .dovetail/deps/<repository-digest>/<full-commit>/ across aliases and project selections. The root workspace’s lockfile controls resolution; a fetched library’s lockfile does not override it.

Workspace builds and tests select local projects. Remote projects supply source and assets without becoming independent output targets, and their tests are not run implicitly.

Private Repositories, Assets, and Editor Navigation

Section titled “Private Repositories, Assets, and Editor Navigation”

Private repositories use the installed Git executable and your configured HTTPS credential helper or SSH agent. An SSH declaration can use git = "git@github.com:acme/private-library.git". Configure access through Git; keep credentials out of Dovetail.toml. Editor fetching is noninteractive.

A dependency can carry prebuilt WebAssembly components, resources, and macro scripts, including transitively. Commit the actual artifact files in Git. Automatic Git LFS hydration, submodule fetching, native build scripts, and release-asset downloading are not supported.

The language server resolves the same dependency graph as the CLI. Go-to-definition opens the checked-out source, and semantic requests use the consuming project’s dependency revision. Fetched files remain available for offline navigation and are read-only dependency sources. Generated component bindings are navigable under .dovetail/generated/.

Use top-level standard-tag to make all standard library projects available at one revision:

compiler-version = "0.1.4"
standard-tag = "<tag>"
[[project]]
name = "api"
root_package = "com.example.api"
packages = ["."]
depends = ["standard-collection", "standard-json"]

Replace <tag> with a tag whose manifest declares the matching compiler version. Currently this shorthand uses https://github.com/somdoron/dovetail.git. It exposes projects rooted at standard or standard.*, retaining their actual project names such as standard-json, while excluding standard.prelude. Each local project still selects the libraries it needs through depends.

The Git dependencies design describes the resolver and compatibility rules in more detail.


  • Every file starts with a package declaration; the package path must match the directory structure.
  • Use imports to bring in types and functions from other packages; one import per line, no wildcards. Use aliased imports when names clash or you want a shorter name.
  • Named extensions must be explicitly imported, including within the same package. Trait implementations follow their own visibility and coherence rules.
  • Visibility is controlled by public, private, and internal so you can hide implementation details and expose a clear API.
  • Git dependencies are declared once per repository and revision, selected through project depends, and pinned in Dovetail.lock. Package imports keep their original names.
  • Circular dependencies are disallowed; the compiler enforces a DAG of package (and project) dependencies, which keeps builds and design predictable.

In the next part, we’ll look at async programming and the await operator.