API reference

Modules

18/18Coverage9/18Strict (zero caveats)

ES module syntax — named/default/namespace imports, re-exports, aliasing, and static CommonJS require — resolved by a whole-program model rather than separate compilation units. The big divergence is architectural: the resolver merges the entry file plus every transitive import into one program before codegen, so there is no runtime module object, no live bindings, and no separate-compilation boundary. Namespace imports and import.meta.url are compile-time-only constructs, and an import cycle is restricted to declarations. Dynamic import() runs its literal-specifier target on first use, sharing the program's modules as in Node; -dynamic-import=lazy moves each target's code into a shared library loaded on first use.

Exact Works, with caveats Not supported

export function / const/let/var / interface / type / enum

Exact
export function f() { ... }; export const x = ...; export interface I { ... }

The export modifier on any top-level declaration — function, const/let/var, interface, type alias, or enum. It is a declaration-level modifier and nothing more: the resolver consumes it entirely while building each file's export table, so codegen never sees an export node.

export function add(a: number, b: number): number {
    return a + b
}
See it in a full example →

Local export list (export { a, b as c }, export {})

Exact
export { a, b as c }; export {}

Exports the file's own top-level declarations, optionally under another name. A name the file does not declare at its top level is a compile error. `export {}` exports nothing: it only marks the file as a module.

const VERSION = "1.0"
function shout(s: string): string { return s.toUpperCase() + "!" }
export { VERSION, shout as loud }
See it in a full example →

import { a, b } from './relative/path'

Exact
import { a, b } from './relative/path'

Named imports from a relative-path module — named imports only, relative paths only (`./`, `../`) with `.ts` auto-appended if omitted. Resolved at compile time against the importing file's own directory (not `cwd`), then merged into the single whole-program AST.

import { add, mul, Point, Direction, squareOf } from './math'
See it in a full example →

Circular imports

Works, with caveats
// a.ts imports b.ts; b.ts imports a.ts

Two files may import from each other and call each other's exported functions. The cycle is resolved once into the merged program.

⚠ Differences from standard
  • Supported only for the declarations-only case — a file in an import cycle can't run arbitrary top-level side-effecting code

Diamond-shaped import graphs

Exact
// a → b, a → c, b → d, c → d

A file imported from multiple places is parsed once and merged once, memoized by absolute path, so a shared dependency in a diamond graph contributes its declarations and runs its top-level code exactly once.

Imported (non-entry) files may run top-level side-effecting code

Works, with caveats
// setup.ts top-level console.log runs before its importer's

An acyclic imported file's top-level statements run exactly once, in real dependency order, strictly before whatever imports it — no restriction to declarations-only. Diamond-shared dependencies still run exactly once via per-path memoization.

import { ready } from './side_effects_setup'

console.log("main: ready = " + ready())
See it in a full example →
⚠ Differences from standard
  • A file that genuinely participates in an import cycle keeps the declarations-only restriction (no bare executable top-level statements), and additionally requires a top-level var/let/const initializer to be a compile-time literal — no TDZ/live-binding modeling

True per-file module scope (mangled internal names)

Exact
// two files may both declare a top-level `helper`

Every top-level declaration gets a file-private mangled name, and every reference to it is rewritten via a real scope-aware walk, so two unrelated files may freely declare the same top-level name. Binding two different files' exports to the same local name in one importing file with no `as` is still rejected — a real conflict, same as real ES modules.

import { run as runA } from './scoping_a'
import { run as runB } from './scoping_b'
See it in a full example →

Import aliasing (import { a as b })

Exact
import { a as b } from './path'

A direct consequence of the per-file rename mechanism: `b` is simply bound to the target's mangled name for `a`, with no separate rename step.

import { run as runA } from './scoping_a'
import { run as runB } from './scoping_b'
See it in a full example →

Type-only imports and exports

Works, with caveats
import type { T } from './path'; import { type T, f } from './path'; export type { T }

Every form is erased with its statement, including a builtin module's types (`import type { IncomingMessage } from 'http'`). A plain named import of a Node class this compiler implements only as a type (`import { IncomingMessage } from 'http'`) is a type import too. Using a type-only import as a value is TS1361, as in tsc.

import type { Box } from './type_only_shapes'
import { type Side, area } from './type_only_shapes'
See it in a full example →
⚠ Differences from standard
  • A Node class implemented only as a type (IncomingMessage, ServerResponse, …) can't be used as a value (x instanceof IncomingMessage is a compile error).

export default

Exact
export default function foo() { ... }; export default 42

Named (function/class/interface/type/enum) and anonymous function/class forms, plus an arbitrary expression (`export default 42`, wrapped as a synthetic const). Exposed only under the export key "default" — a named default export's own name stays usable for intra-file self-reference (recursion, etc.) but is not itself importable via `import { foo }`, matching real ES modules.

export default function greet(name: string): string {
    return "hello " + name
}
See it in a full example →

import x from './path' (default import)

Exact
import x from './path'; import x, { a, b } from './path'

Also combinable with a named list in one statement (`import x, { a, b } from './path'`). Sugar over the same specifier machinery as a named import — no separate resolver mechanism.

import greet, { shout } from './greet'
See it in a full example →

import * as ns from '...' (namespace import)

Works, with caveats
import * as ns from './path'

`ns.member` is rewritten directly to that member's mangled name during resolution, in values, `new ns.C()`, `extends ns.C` and types (`x: ns.T[]`). Only actually-exported members are reachable (`ns.privateHelper` is rejected, same visibility rule as a named import); `ns.default` reaches a default export the same way any other member does.

import * as greetLib from './greet'
console.log(greetLib.shout("hey"))
console.log(greetLib.default("there"))
See it in a full example →
⚠ Differences from standard
  • Compile-time-only — ns is never a runtime value, usable only as the object of a non-computed dotted access; assigning it to a variable, passing it as a value, or ns["x"] reaches codegen as a plain undefined reference rather than a dedicated error message

Static CommonJS require('<literal>')

Works, with caveats
const x = require('m'); const { a, b } = require('m')

Desugared to the equivalent ES import at parse time, so it resolves and dispatches identically to `import` and interoperates with `import` in the same file. `const x = require('m')` binds the namespace (whole module object); `const { a, b } = require('m')` is a named import; bare `require('m')` is side-effect-only.

const path = require('path')
const { basename, extname } = require('path')
See it in a full example →
⚠ Differences from standard
  • Top-level, string-literal specifier only. const x = require('m') binds the namespace (whole module object); const { a, b: c } = require('m') is a named import; bare require('m') is side-effect-only. A destructuring default/nested-pattern/rest on a require binding, or a dynamic require(expr) / a require nested in a function body (lazy loading), is a clean compile error — deferred to the runtime/lazy-loading capability

Re-exports (export { x } from './other', export * from './other')

Works, with caveats
export { x } from './other'; export * from './other'

Named re-export list (with `as` aliasing) and star re-export both work, including default re-export in either direction. `export *` never forwards a default, matching real ES modules. Chains of arbitrary depth resolve transitively. A re-export forwards a name to this file's importers only — it does not become a usable bare identifier inside the re-exporting file itself.

export { add } from './re_export_core'
export { mul as multiply } from './re_export_core'
export { default as greet } from './re_export_core'
See it in a full example →
⚠ Differences from standard
  • No namespace re-exports (export * as ns from) — parsed and explicitly rejected with a clear error rather than silently mis-parsed, no concrete use case yet
  • Re-exporting from a built-in module (export { readFileSync } from 'fs') is an explicit, rejected scope cut, not an oversight

Bare/package-style imports (import x from 'somepackage')

Works, with caveats
import x from 'somepackage'

A bare specifier resolves against `klain_modules/<name>/klain.json`'s "main" field, found by walking upward once from the entry file's own directory (not re-walked per importing file, unlike Node). A real built-in module name (`'fs'`/`'path'`/etc.) is always checked, and always wins, before any `klain_modules` lookup is attempted.

import { greet } from 'greeter'
See it in a full example →
⚠ Differences from standard
  • klmpm is Stage 1 only — deliberately just the resolution half; no klmpm tool exists yet to fetch/version/lock a dependency, so a klain_modules/<name>/ directory has to be hand-constructed today
  • npm/node_modules interop is a separate, unstarted, differently-scoped mechanism

Dynamic import(...)

Works, with caveats
const m = await import('./mod')

By default the target is compiled into the binary and its top-level runs on first import(), as a task on the program's one event loop: it shares every module instance with the program, a top-level throw rejects the import(), and an unsettled top-level await exits with code 13, as in Node. -dynamic-import=lazy keeps those semantics with each target's code in a shared library beside the binary, loaded on first use. -dynamic-import=isolated makes each target its own program with its own module instances, so its import-time state stays apart from the program's.

const m = await import('./mod')
console.log(m.greeting, m.answer)
⚠ Differences from standard
  • String-literal specifier only — a runtime-computed import(expr) is a clean compile error
  • The result object exposes annotated scalar/string exports only; function/class/array/object exports are omitted
  • import('node:…') of a builtin module is a clean compile error
  • lazy and isolated need the sibling <binary>.d/ directory and refuse --static; on Windows lazy compiles thread-locals emulated (a call per access)
  • isolated is a deliberate divergence: the target gets its own instance of every module it imports

import.meta.url

Works, with caveats
import.meta.url

Resolved entirely at compile time, per file, into a plain string literal (`"file://" + that file's own absolute path`, matching real Node/browser convention) — codegen never sees a real module metadata object.

console.log(import.meta.url.startsWith("file://"))
console.log(import.meta.url.endsWith("import_meta_url.ts"))
See it in a full example →
⚠ Differences from standard
  • import.meta.url is the only supported member — bare import.meta or any other member (import.meta.resolve, etc.) is a clean parse-time error

Import-gated built-in bindings (fs/path/os/querystring/assert/http/cluster/Memory)

Exact
import fs from 'fs'; import { readFileSync } from 'fs'; import test from 'node:test'

`import fs from 'fs'`, `import * as fs from 'fs'`, and `import { readFileSync } from 'fs'` (including aliasing and combining a default with named specifiers in one statement) all bind through the same scope-aware machinery as a real file import; every Node-named builtin also resolves under the node: prefix. A local variable of the same name correctly shadows it; importing an unknown member is a clean, explicit rejection. Node-style built-ins require a default, namespace, or named import; process/console stay ambient by design.

import { readFileSync, writeFileSync as wfs, existsSync, unlinkSync } from 'fs'
See it in a full example →