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:
// 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:
// 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):
// 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:
// 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.
// 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 form | Meaning |
|---|---|
"./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, andHashMaphave no sugar and are imported explicitly:import { Vec } from "@core".@io— process I/O:stdout/stderrwrite streams,stdinread stream,args()for the argument vector. Read/write errors come back as error unions.@mem— memory: theAllocatorinterface,systemAllocator,ArenaAllocator,FixedBufferAllocator, byte ops (copy,equal,compare,fill), and the trust-boundary helpers (transmuteTo,offsetPtr).@errors— the error kit:Error,ErrStack, stack capture.@testing—describe/it/expect, run byvyi test(guide 16).@trace— call-frame introspection:trace,currentPos.@crypto—randomBytes.@libc,@wasi-preview1,@wasm— the platform FFI layers that@io/@memsit 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:
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:
// 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:
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.vyiThe fields you'll actually touch:
name/version— the package's identity:nameis how consumers will refer to it,versionis its semantic version.lang— the Vyi language version to build against. This also pins the stdlib: every@pkgimport 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 anunknownDependencyerror. *(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-levelmain. 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 nomain; it's conventionally a barrel that re-exports the public API. A package can have bothentries(its tools) andexport(its library surface).source— optionally scopes which files belong to the package:root(directory the sources live under) plusinclude/excludeglob arrays. Absent means every.vyiunder 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.