Guides · 14

Modules and packages

Every .vyi file is a module. There is no module keyword and no package-level namespace object: a file's top-level declarations are private to it unless marked export, and an import binds the names you ask for directly into your file's scope. A program is the import graph rooted at an entry file — the compiler starts at the entry, follows every import, and compiles what it reaches.

Imports and exports

export makes a declaration visible to other files; import { … } from pulls exported names in. Relative paths resolve against the importing file's directory, and the .vyi extension is implied:

vyi
// file: geometry.vyi
export struct Point {
    pub x: i32,
    pub y: i32,
}

export fn (a: Point) distanceSquared(b: Point) i32 {
    con dx = a.x - b.x
    con dy = a.y - b.y
    return dx * dx + dy * dy
}

// file: main.vyi
import { Point } from "./geometry"
import { stdout } from "@io"

fn main() !u8 {
    con a = Point{ x: 0, y: 0 }
    con b = Point{ x: 3, y: 4 }
    stdout.writeInt(i64(a.distanceSquared(b)))
    stdout.write("
")
    return Ok(0)   // 25
}

Notice what did not happen: there is no geometry.Point, no qualified access at all. Imports bind bare names. This is a deliberate consequence of Vyi's method model — methods live in scope, not inside types, so scope (not a namespace path) is the unit of code organisation. Notice also that main.vyi never imported distanceSquared, yet the method call works — more on that below.

Anything importable can be exported: fn (including methods), struct, enum, interface, type, and con/mut bindings. Asking for a name the target doesn't export is a compile error at the import line:

vyi-error
// file: geometry.vyi
export struct Point { pub x: i32, pub y: i32 }
fn helper(p: Point) i32 { return p.x }   // not exported

// file: main.vyi
import { helper } from "./geometry"   // error: "./geometry" has no exported member "helper"

fn main() !u8 { return Ok(0) }

export and pub answer different questions: export controls whether the name is visible to other files; pub controls whether a struct's fields are. An exported struct with non-pub fields is an opaque type — other files can hold and pass it, but only its defining file can touch the insides (guide 03):

vyi-error
// file: widget.vyi
export struct Widget {
    pub id: i32,
    secret: i32 = 0,
}

// file: main.vyi
import { Widget } from "./widget"

fn main() !u8 {
    con w = Widget{ id: 1 }
    con s = w.secret   // error: field "secret" is private to its defining file
    return Ok(0)
}

Aliases, barrels, and re-exports

An import binding can rename: { exported: localName }. Useful when two files export the same name, or just for brevity:

vyi
// file: geometry.vyi
export struct Point { pub x: i32, pub y: i32 }
export fn (a: Point) manhattan(b: Point) i32 {
    return (a.x - b.x) + (a.y - b.y)
}

// file: main.vyi
import { Point: P } from "./geometry"
import { stdout } from "@io"

fn main() !u8 {
    con a = P{ x: 5, y: 5 }
    con b = P{ x: 1, y: 2 }
    stdout.writeInt(i64(a.manhattan(b)))
    stdout.write("
")
    return Ok(0)   // 7 — methods follow the alias
}

export { list } re-exports names the file imported, which lets you build a barrel: one file that presents a package's public surface while the implementation lives in per-concern files. The stdlib's @core is exactly this shape.

vyi
// file: impl.vyi
export fn triple(n: i32) i32 {
    return n * 3
}

// file: barrel.vyi
import { triple } from "./impl"
export { triple }

// file: main.vyi
import { triple } from "./barrel"
import { stdout } from "@io"

fn main() !u8 {
    stdout.writeInt(i64(triple(14)))
    stdout.write("
")
    return Ok(0)   // 42 — resolves through the chain to impl.vyi
}

Finally, an import can omit the binding list entirely — import "./file" — to load a file purely for what it declares. You'll see why that's useful in the method section.

Three kinds of import path

Path formMeaning
"./x", "../x"relative — a file in your own project
"@pkg"a standard-library package, versioned with the language
"pkg"a third-party dependency declared in your manifest's deps

Relative imports must stay inside the package root (the directory holding package.vyipac). An import that escapes — "../../somewhere-else" — is a compile error, importEscapesRoot. A package is a closed unit; if you need code from outside, it has to arrive as a dependency, not a path.

The standard packages

Stdlib packages are imported with an @ prefix. What ships today:

  • @core — the foundational types: Optional, Result, Array, ArrayPointer, Range, String, Vec, HashMap. The first five back language sugar (?T, !T, [N]T, [*]T, a..b) and are auto-loaded into every file — you never import them. String, Vec, and HashMap have no sugar and are imported explicitly: import { Vec } from "@core".
  • @io — process I/O: stdout/stderr write streams, stdin read stream, args() for the argument vector. Read/write errors come back as error unions.
  • @mem — memory: the Allocator interface, systemAllocator, ArenaAllocator, FixedBufferAllocator, byte ops (copy, equal, compare, fill), and the trust-boundary helpers (transmuteTo, offsetPtr).
  • @errors — the error kit: Error, ErrStack, stack capture.
  • @testingdescribe/it/expect, run by vyi test (guide 16).
  • @trace — call-frame introspection: trace, currentPos.
  • @cryptorandomBytes.
  • @libc, @wasi-preview1, @wasm — the platform FFI layers that @io/@mem sit on. You rarely import these directly.

Three more @ names are synthesized by the compiler rather than shipped as source: @c (the C FFI namespace, guide 15), @intrinsics (trust-boundary primitives like markStablePtr), and @target — known-value constants describing the build target. Because @target values are known at compile time, a file-scope if over them selects declarations at compile time, and only the live branch survives:

vyi
import { targetOS } from "@target"
import { stdout } from "@io"

if targetOS == "wasm" {
    con pageSize = 65536
} else {
    con pageSize = 4096
}

fn main() !u8 {
    stdout.writeInt(i64(pageSize / pageSize))
    stdout.write("
")
    return Ok(0)
}

This is how the stdlib itself targets wasm and native linux from one source tree.

Methods travel with types

A method declared in the same file as its receiver type travels with the type: import the type, and those methods come along. That's why the first example could call a.distanceSquared(b) after importing only Point.

A method declared in a different file is part of that file's exports, and you bring it into scope like any other name — or, since methods are called through the receiver rather than by their bare name, with a side-effect import of the whole file:

vyi
// file: shape.vyi
export struct Circle { pub r: i32 }

// file: area.vyi
import { Circle } from "./shape"
export fn (c: Circle) area() i32 { return 3 * c.r * c.r }

// file: main.vyi
import { Circle } from "./shape"
import "./area"                    // load the extension methods
import { stdout } from "@io"

fn main() !u8 {
    con c = Circle{ r: 2 }
    stdout.writeInt(i64(c.area()))
    stdout.write("
")
    return Ok(0)        // 12
}

Drop the import "./area" line and the call fails: `type Circle has no method "area"`. This is the heart of Vyi's method model — which methods a type has is a property of what's in scope, not of the type's definition. Anyone can extend any type, including types from other packages, and the extension is visible exactly where it's imported. Method sets are deliberately not part of type identity: a Circle is the same nominal type everywhere; only the operations available on it vary by scope. (This is also what interfaces build on — boxing a value captures the methods in scope at the cast site, guide 12.)

Two different types can export same-named methods from different files with no conflict — the receiver type picks the method. When two declarations share the same receiver type and signature, the definition closest to the use site wins: a file's own method beats one that traveled in with an import, and each file's calls resolve against its own view. A library's internal calls keep using the library's methods even when your program declares its own version — your version wins only at your own call sites.

Packages and the manifest

A package is a directory tree with a package.vyipac manifest at its root. The compiler finds the manifest by walking up from the entry file; every .vyi under that root belongs to the package. The manifest is YAML:

yaml
name: my-app
version: 0.1.0
lang: 0.0.1

deps:
  json: ^1.0.0

entries:
  my-app: src/main.vyi
  my-app-admin: src/admin.vyi

export: src/lib.vyi

The fields you'll actually touch:

  • name / version — the package's identity: name is how consumers will refer to it, version is its semantic version.
  • lang — the Vyi language version to build against. This also pins the stdlib: every @pkg import in the package resolves at this version.
  • deps — third-party dependencies, name → version constraint. A declared dependency is imported by its bare name: import { decode } from "json". An undeclared bare import is an unknownDependency error. *(The declaration side works today; the fetch-and-install tooling that resolves a declared dep into the local dependency store is planned.)*
  • entries — the package's executables: each entry names a bin and points at its entry file, which must contain a top-level main. Each entry roots its own program — the import graph reached from that file. One package can ship several bins.
  • export — the package's library entry: the single file other packages get when they import this package by name. It needs no main; it's conventionally a barrel that re-exports the public API. A package can have both entries (its tools) and export (its library surface).
  • source — optionally scopes which files belong to the package: root (directory the sources live under) plus include/exclude glob arrays. Absent means every .vyi under the manifest's directory.

Capability flags and target support — hasBuiltIns, hasInlineMarkers, hasFileSystemAccess, hasNetworkAccess, and supportedTargets — are not set in application manifests. The package registry records them when a package is published (planned).

No manifest is required to get started: a lone file with only relative and @pkg imports compiles fine (vyi run main.vyi), with the stdlib at its default version. Add package.vyipac when the project grows a root worth enforcing, dependencies, or named entries.

See also: reference/methods-dispatch, reference/toolchain/manifest.