Skip to content
Noodle
InstallLearnPlayground
GitHub

Extern bindings

An extern declaration is a typed boundary between Noodle and the host runtime. The Noodle source declares the name, parameters, and result type; a companion ES module supplies the implementation. The compiler type-checks calls against the Noodle declaration, but it does not inspect the JavaScript function’s body.

An external function has no Noodle body. Use @link when the host export has a different name from the Noodle declaration:

module Clock
@link("now_ms")
export extern func now_milliseconds() -> Int
func main() -> Unit do
Debug.trace(now_milliseconds())
end

Put the host implementation in a companion file next to the primary source:

clock.nl
clock.extern.mjs

The companion’s named export must match the @link key:

export const now_ms = () => Date.now();

For a source file named clock.nl, the JavaScript backend looks for clock.extern.mjs. A missing companion is a build error. A missing or non-callable named export, an exception thrown by the companion, or a value that violates the declared boundary follows the host runtime’s normal failure behavior; Noodle does not insert an implicit conversion layer.

With @link("key"), key is the exact named export selected from the companion module. @link accepts no argument or one string argument:

module Environment
export extern func read_mode() -> String
@link("set_mode")
export extern func write_mode(value : String) -> Unit

The first declaration binds to read_mode because no @link attribute was provided. The second binds to set_mode. The binding key is implementation metadata; it is not part of the exported function type or the module’s public signature. Consumers only see the typed Noodle declaration.

extern is a contextual word. It introduces an external declaration only in the expected declaration forms; it remains available as an ordinary lower-case identifier elsewhere. External functions may be generic, but their parameters are positional. They use the same visibility rules as ordinary functions: without export, the declaration is private to its module.

An extension can provide an external method when its implementation needs a host operation but should be called with dot syntax:

module NativeText
extension TextMethods for String
@link("text_length")
extern method length(value : String) -> Int
end

The host companion exports text_length with the same positional calling shape:

export const text_length = (value) => value.length;

An extern datatype is different from an external function. It declares a matchable nominal type whose representation is supplied by the host. A non-empty extern datatype names its companion classifier with @js_recognizer and tags every constructor with @js_tag — either an integer or a non-empty string tag, one shape per datatype (all-int or all-string, never a mix):

module BodyInitBridge
@js_recognizer("recognize_body")
export extern datatype BodyInit
@js_tag(1) Blob
@js_tag(2) Stream
end
export const recognize_body = (value) => {
if (value instanceof Blob) return 1;
if (value instanceof Stream) return 2;
throw new Error("unrecognized BodyInit value");
};

The recognizer contract: a synchronous, pure function from host value to Int | String tag, total over the datatype’s inclusive constructor set (direct constructors plus inherited bases). The declared tag kind selects the comparison primitive. Lowering calls the expected type’s recognizer once per scrutinee and switches on the result; an unknown tag or a throwing recognizer is a loud runtime error. @link survives only on payload fields, where it renames the read host property. The same trust posture as extern func returns applies: recognizer purity, totality, and base/derived agreement are conventions, not checked rules.

Field names are an implementation detail, not an ABI. Whole-program builds rename record and datatype-payload fields to short generated names, so a host companion must not rely on the source spelling of a field it was not promised.

Mark a field the host reads or constructs with a bare @link attribute. The attribute pins the runtime property name to the source name:

module CursorBridge
export opaque datatype Cursor
Cursor{
@link offset: Int,
}
end

The same spelling works for record types, which is also how a companion-built value keeps its shape:

module ChildBridge
export type ChildResult = {
@link exit_code: Int,
@link output: String,
}
export const spawn_child = (command, on_exit) => {
const child = start_child(command);
child.on("exit", code => on_exit({exit_code: code, output: ""}));
return child;
};

The companion reads cursor.offset in one direction and constructs {exit_code, output} in the other; both spellings hold because the fields are pinned. @link on a field takes no arguments; @link("other") is an error. Fields without the attribute may still cross an extern boundary, but they arrive opaque: the host sees whatever name the build assigned, with no guarantee. Publish a field to the host only by annotating it.

Tuple positions (_0, _1, …) never need the attribute: they are positional, not names, so there is nothing to rename.

The companion belongs to the package containing the primary .nl source. It is not a separately importable Noodle module and it is not listed in nlpkg.json as a source dependency. An exported extern function is published to other Noodle modules through its ordinary function signature; the absolute path of the .extern.mjs file is kept out of .nli signatures.

Parts do not create extra host boundaries. If a primary source includes functions.part, an extern declaration in that part still uses the primary module’s one .extern.mjs companion. A .part file never gets its own companion.

Keep host code narrow: use an extern binding for platform capabilities that Noodle cannot perform directly, such as filesystem access, process control, JavaScript APIs, or a promise bridge. Put ordinary language logic in Noodle so it remains statically checked, testable, and portable across host setups.

For an asynchronous host operation, declare the promise it produces and settle it only after the operation really stopped; the asynchronous code guide describes the ownership, completion, and runtime-failure contract that a host operation participates in. For compiler-provided operations that require compiler knowledge, use the standard library’s native or runtime-provider mechanisms instead of inventing an extern binding; see Structural capabilities for an example of the distinction.

Next: Prelude and modules.