Skip to content

Exporting Z through C

Interop works in both directions. export c function keeps a function callable from Z while additionally publishing a stable C symbol:

function double(value: i32): i32 {
return value * 2;
}
export c function z_math_add(left: i32, right: i32): i32 {
return left + right;
}
export c function z_math_answer(): i32 {
return z_math_add(double(10), 22);
}

The C name is exact and intentionally explicit. Ordinary Z callers invoke the Z implementation directly; they do not cross the generated foreign wrapper.

Z Generated C
i8…i64 int8_t…int64_t
u8…u64 uint8_t…uint64_t
isize, usize intptr_t, uintptr_t
f32, f64 float, double
boolean bool
void void

An owned Z String input projects as borrowed const char * at the C ABI:

export c function z_text_byte_length(text: String): usize {
return text.byteLength;
}

The wrapper validates non-null NUL-terminated UTF-8, copies it once into owned Z storage, and runs ordinary Z cleanup on every path. Z-to-Z calls pass the String directly and make no boundary copy. Returning an owned String to C remains unsupported until the public ABI can state allocator and release rules.

Use a read-only Slice<u8> when the input is bytes rather than promised text:

export c function byteCount(bytes: Slice<u8>): usize {
return bytes.length;
}

The generated C header exposes a pointer and length, not Z’s slice layout:

Z_API uintptr_t byteCount(const uint8_t *bytes_data, uintptr_t bytes_length);

There is no allocation, byte copy, UTF-8 validation, or NUL scan in this projection. Embedded NUL and invalid UTF-8 remain ordinary bytes. Each slice expands to its own adjacent pair; ordinary Z callers still pass a single slice or compatible array. Slice is already borrowed, so omit the in prefix.

The caller keeps the byte range valid and unmodified until the synchronous call returns. (NULL, 0) is empty; (NULL, positive_length) traps. The wrapper cannot verify arbitrary pointer validity or concurrent host mutation. Z code cannot mutate or retain the slice; explicitly copy into owned byte storage if the data must survive the call.

Mutable slices, other element types, returned slices, and async exports remain unsupported. Receiving bytes never implicitly converts them to String.

Ordinary Z structs do not silently become public native layouts. Mark the representation explicitly:

c struct ZMathPoint {
x: i32;
y: i32;
}
export c function z_math_translate(
point: ZMathPoint,
dx: i32,
dy: i32
): ZMathPoint {
return ZMathPoint({
x: point.x + dx,
y: point.y + dy,
});
}

Fixed-representation enums retain nominal Z matching while publishing an exact integer representation:

c enum ZMathStatus: i32 {
negative,
zero,
positive,
}

The wrapper validates enum inputs from C before they enter safe Z, because a C caller can manufacture an integer outside the declared variants.

The first c struct tier accepts public mutable scalar or nested C-layout fields and rejects cleanup-bearing, generic, private, readonly, class, or executor-affine layouts that a plain C header could not preserve safely.

Project configuration chooses the artifact independently from source ABI:

{
"target": {
"kind": "static-library",
"name": "zmath",
"entry": "library.zs"
}
}
Terminal window
z build . --release

The static project publishes:

build/libzmath.a
build/zmath.h

Use "shared-library" to publish a .dylib, .so, or .dll plus the same checked header contract. Executables remain the default when kind is omitted.

The public header includes target-scoped initialization and shutdown functions. A native host initializes before its first exported call and shuts down only after exported calls, native workers, and registered callbacks have drained.

Every public wrapper enters and leaves the bounded Z runtime. This makes foreign-created threads, ARC state, callbacks, and future runtime-owned roots safe without charging ordinary Z-to-Z calls for foreign-boundary accounting.

A project may configure one cleanup-bearing runtime root:

{
"target": {
"kind": "static-library",
"name": "application_core",
"entry": "library.zs",
"runtime": {
"initialize": "initializeRuntime"
}
}
}

The initializer’s owner remains alive until host shutdown, providing a natural home for application identity, Once<T>, callback registrations, and other embedding-lifetime resources.

A root such as OnceLifetime<Engine> can be thread-independent when Engine is a compiler-proven shareable class (for example, a readonly Mutex<Registry> field). The host can initialize and shut down that library on worker threads without main-thread callbacks if both generated requirement macros are zero. Main-affine classes elsewhere in the library still keep those requirements; a shareable root does not override them.

The compiler keeps internal Z symbols private and places the stable ABI in a small public wrapper. That wrapper is where it can:

  • validate hostile C inputs;
  • create owned Z values from foreign representations;
  • enter and leave the embedded runtime;
  • preserve future compiler freedom over internal names and calling conventions.

The generated header includes an extern "C" guard, so the same library is directly consumable from C++, Objective-C++, and Swift’s C importer.

Runnable examples: static library, borrowed bytes, shared library, and embedded runtime root.

Continue with the reference map for exact ABI restrictions and embedding lifecycle details.