Kinda

Kinda is a Zig-first binding framework for Elixir.

It is designed for projects that need to bind a large C API to BEAM without hand-writing every NIF, resource wrapper, and dispatch stub.

The shortest useful description is:

This makes kinda a better fit for projects like beaver, where the problem is not “write one ergonomic NIF”, but “keep a large drifting native API coherent over time”.

Run One Example

If you want the shortest stable repo-local proof of the wrapper/reporting surface, run:

mix kinda.wrapper.example

Useful modes:

mix kinda.wrapper.example --json
mix kinda.wrapper.example --report-only

To verify the whole repo from the root, including:

run:

mix kinda.verify

This is the command CI should prefer for repo-level verification.

If you only want the bundled application example from the repo root, run:

mix kinda.example.verify

If you want the same thing without compiling the project first, run:

elixir examples/wrapper_reporting.exs

It prints:

What Kinda Ships Today

Core runtime/building blocks:

Wrapper extraction/generation blocks:

That means kinda now has an explicit split between:

  1. generic wrapper extraction/generation
  2. consumer-owned policy
  3. callback-bridge backlog and runtime substrate

Mental Model

Kinda is not primarily a “write NIFs directly in Zig” library.

It is a framework for binding a C library through three layers:

  1. wrapper surface
    • a .h file says what native functions and types you want to expose
  2. framework surface
    • kinda extracts that surface into a normalized manifest
    • kinda generates generic wrapper outputs from the manifest
  3. consumer surface
    • your project decides naming, scheduler routing, diagnostics variants, unsupported APIs, and callback-heavy backlog

For a large binding, this is the important compression:

Quick Example

Define a boundary codec, generate the raw NIF surface, and bind a resource kind directly to it:

defmodule Foo.Native do
use Kinda.Codec
end
defmodule Foo.CAPI.Raw do
use Kinda.CodeGen,
with: Foo.Generated,
root: Foo.CAPI,
codec: Foo.Native,
surface: :raw
@on_load :load_nif
def load_nif do
:erlang.load_nif(~c"path/to/foo_nif", 0)
end
end
defmodule Foo.CAPI do
use Kinda.CodeGen,
with: Foo.Generated,
root: __MODULE__,
raw_module: __MODULE__.Raw,
codec: Foo.Native,
surface: :public
end
defmodule Foo.Handle do
use Kinda.ResourceKind,
raw_module: Foo.CAPI.Raw,
codec: Foo.Native
end

Generated public wrappers call concrete functions in Foo.CAPI.Raw, unwrap resource arguments, and pass only the returned value through Foo.Native. The codec never selects a native function. Keeping :public wrappers and :raw NIF stubs in separate modules lets large bindings compile both surfaces independently and avoids a generated proxy layer. The raw module owns NIF loading, so the native entry module must be Elixir.Foo.CAPI.Raw.

Generated use Kinda.CodeGen modules now expose one formalized declaration surface from the same typed source:

When a generator module implements declaration_manifest/0, Kinda.CodeGen can now source generated function/type declarations directly from that unified manifest instead of reconstructing them from parallel callbacks. That callback is now the canonical declaration interface in kinda: it may return a loaded manifest value or a checked-in .ex / .json sidecar path. Kind-derived helper NIFs still come from kinds()/0.

kinda now also exposes a top-level downstream declaration facade through Kinda.Declaration. That facade sits over the same underlying Kinda.CodeGen.DeclarationSurfaces IR and formalizes the distinction between the declaration source and the generated module surface:

Downstreams should treat Kinda.Declaration.from_generator/2 as the formalized public resolution interface. Generated modules expose that same resolved IR directly through __kinda_declaration_surfaces__/0. Inside that IR, the canonical resolved payload is the declaration manifest itself; nif_decls, type_decls, and signature_manifest are no longer stored a second time on the resolved surface. That lets downstream repos such as Beaver unify their own public rewrite/pass DSL names without re-owning the declaration contract.

When that manifest includes projected records, Kinda.CodeGen also emits deterministic public type aliases such as foo_handle_record()/0 from the same single source. These generated aliases use atom field keys derived from extracted C field names, while the machine-readable manifest keeps the original string names.

For larger generated bindings, the newer wrapper pipeline is usually more important.

Wrapper Pipeline

The wrapper pipeline is the part of kinda that scales to large native APIs.

1. Extract a manifest

manifest = Kinda.Wrapper.Extract.from_clang_ast(ast_json)

The result is a framework-owned shape:

%Kinda.Wrapper.Manifest{
records: [
%Kinda.Wrapper.CRecord{
name: "MlirContext",
kind: :struct,
fields: [
%Kinda.Wrapper.CField{
name: "ptr",
ctype: %Kinda.Wrapper.CType{spelling: "const void*", kind: :pointer}
}
]
}
],
functions: [
%Kinda.Wrapper.Function{
name: "mlirContextCreate",
params: [],
arity: 0,
doc: "Creates a new MLIR context.",
param_ctypes: [],
return_ctype: %Kinda.Wrapper.CType{
spelling: "MlirContext",
kind: :unknown
}
}
]
}

2. Apply consumer policy

defmodule MyLib.WrapperPolicy do
@behaviour Kinda.Wrapper.Policy
def generation_blocker_entries, do: %{}
def generation_blocked?(_name), do: false
def generation_blocker_reason(_name), do: nil
def callback_bridge_entries, do: %{}
def callback_bridge?(_name), do: false
def callback_bridge(_name), do: nil
def variants(name), do: [{:normal, name, name}]
def public_name({_kind, public, _base}), do: public
def elixir_params({_kind, _public, _base}, params), do: params
def dirty({_kind, _public, _base}), do: false
def typespec_field(_record, field), do: ...
def typespec_params(_variant, function), do: ...
def typespec_return(_variant, function), do: ...
def zig_entry({_kind, _public, base}), do: ~s{nif("#{base}"),}
end

3. Generate outputs

Kinda.Wrapper.Generate.render_elixir_manifest(manifest, MyLib.WrapperPolicy)
Kinda.Wrapper.Generate.render_zig_nif_entries(manifest, MyLib.WrapperPolicy)
Kinda.Wrapper.Generate.declaration_surfaces_struct(manifest, MyLib.WrapperPolicy)
Kinda.Wrapper.Generate.declaration_manifest_struct(manifest, MyLib.WrapperPolicy)
Kinda.Wrapper.Generate.declaration_manifest(manifest, MyLib.WrapperPolicy)

Callback-Bridge Backlog

Not every native function should be emitted as a plain generated wrapper.

Some functions need:

Kinda now represents that explicitly with Kinda.Wrapper.CallbackBridge.

Example:

Kinda.Wrapper.CallbackBridge.required(:some_function,
scheduler: :dirty_cpu,
facets: [:beam_callback, :scheduler_contract]
)

This metadata does not invent a consumer ABI automatically. It is the framework-owned policy layer that lets a consumer say:

Pure kind-surface gaps belong to a different path. In a consumer such as beaver, handle-like CAPIs are often unblocked by extending the Zig-side kind surface and the matching Elixir KindDecl surface; callback-bridge metadata is specifically for the remainder that kind-surface sync cannot solve.

Callback Runtime

Consumers can implement those callback bridges with the generic Zig runtime:

const kinda = @import("kinda");
const Dispatcher = kinda.callback_runtime.Dispatcher(.{ "run", "destruct" });
const dispatcher = try Dispatcher.initWithOptions(handler_pid, .{
.timeout_ms = 30_000,
});
dispatcher.setCallback("run", run_callback_term);
const response = try dispatcher.invoke("run", message_env, .{argument_term});

On the BEAM side, the consumer supplies its own reply NIF while Kinda normalizes success, expected failure, and exceptions:

Kinda.CallbackRuntime.invoke(
reply_token,
fn -> {:ok, run_callback.()} end,
&MyNative.my_raw_callback_reply/2
)

Callbacks with scalar, enum, or projected handle results use invoke_reply/4. The consumer validates its own resource before completing the shared token:

Kinda.CallbackRuntime.invoke_reply(reply_token, callback, fn token, outcome ->
MyNative.reply_projected_result(token, outcome)
end)

The consuming NIF library exports and opens the shared reply resource:

const callback_nifs = .{
kinda.callback_runtime.ReplyToken.nif("my_raw_callback_reply"),
kinda.callback_runtime.ReplyToken.codeNif("my_raw_callback_reply_code"),
kinda.callback_runtime.ReplyToken.cancelNif("my_raw_callback_cancel"),
};
export fn nif_load(env: kinda.beam.env, _: [*c]?*anyopaque, _: kinda.beam.term) c_int {
kinda.callback_runtime.ReplyToken.open(env);
return 0;
}

Dispatcher owns copied callback terms and its persistent environment. Each invocation sends {callback_name, reply_token, callback_fun, dispatcher_id, ...args}, then waits on a non-scheduler native thread. The process replying through the consumer-exported NIF becomes the next callback owner. Native callback signatures, argument conversion, domain state, and diagnostics remain the consumer's responsibility.

Waits are bounded to 30 seconds by default. A response reports whether it was replied, canceled, dropped, or timed out; completion after any terminal state returns stale. Dropping the reply resource or terminating its owner therefore cannot leave a native worker waiting forever. ReplyToken and consumer-owned registration resources use NIF resource-type takeover so live resources keep the correct native library generation pinned across reload and unload.

kinda.callback_adapter provides the reusable projection shapes used by consumer ABI trampolines: one handle, ranges of handles, scalar results, enum results, and validated consumer projections. It never interprets an MLIR- or library-specific handle.

Callback-bridge manifest version 2 records runtime_backed, runtime, owner, destructor, lifetime, scheduler, and timeout_ms. Entries built with Kinda.Wrapper.CallbackBridge.runtime_backed/2 no longer carry a callback_bridge_required blocker and their consumer-provided declaration variant remains in the normal resolved declaration surface.

Reporting Surface

Kinda now exposes one canonical declaration contract plus one derived signature view around that wrapper surface:

  1. resolved declaration surfaces
  2. unified declaration manifest
  3. callback-bridge manifest

Resolved declaration surfaces:

Kinda.Wrapper.Generate.declaration_surfaces_struct(manifest, policy)

This is now the canonical wrapper-side in-memory declaration IR in kinda. It carries:

Unified declaration manifest:

Kinda.Wrapper.Generate.declaration_manifest_struct(manifest, policy)
Kinda.Wrapper.Generate.declaration_manifest(manifest, policy)

The declaration-manifest struct is now the canonical resolved payload stored inside DeclarationSurfaces, and the map form is the JSON-friendly serialization. It keeps:

Internally, Kinda.CodeGen.DeclarationManifest.build/2 is now the canonical way to derive declaration metadata from typed wrapper facts, including the generated TypeDecl layer. That keeps type declarations sourced from the same declaration contract rather than from a parallel signature-only path.

When that declaration contract is consumed by use Kinda.CodeGen, the formal resolution path now lives in Kinda.CodeGen.DeclarationSurfaces.from_generator/2, so downstreams do not have to reimplement normalization or merge logic just to observe the final generated declaration surface. That resolved surface is now materialized as Kinda.CodeGen.DeclarationSurfaces, rather than an ad hoc map.

The typed signature manifest remains available as a derived compatibility view:

Kinda.Wrapper.Generate.signature_manifest(manifest, policy)

The callback-bridge backlog remains a separate reporting mode:

  1. human-readable report
  2. machine-readable manifest

Human-readable:

Kinda.Wrapper.Generate.render_callback_bridge_report(manifest, policy)

Machine-readable:

Kinda.Wrapper.Generate.callback_bridge_manifest(manifest, policy)

The manifest is versioned and JSON-friendly:

{
"version": 1,
"entries": [
{
"function": {
"name": "mlirTypeConverterAddConversion",
"arity": 1,
"params": ["converter"]
},
"callback_bridge": {
"function": "mlirTypeConverterAddConversion",
"reason": "callback_bridge_required",
"unblock_path": "callback_bridge_runtime",
"scheduler": "unspecified",
"facets": ["beam_callback", "rich_input_decoder"]
}
}
]
}

This is the main new “reporting surface” for consumers and CI.

The repo-local example above exercises this end to end.

Build / Prebuilt Surface

Kinda ships Kinda.Precompiler, which consumers can use with elixir_make precompiled builds.

Example:

def project do
[
make_precompiler: {:nif, Kinda.Precompiler}
]
end

Today this is target-selection substrate, not a full RustlerPrecompiled-style product story.

What Kinda Is Good At

What Kinda Is Not Yet

Kinda is not yet a Rustler-complete framework.

What is still missing:

Status

Kinda is already useful as a framework substrate.

It is now moving from:

toward:

That is the right frame to evaluate future work.