Skip to content

Repository files navigation

genffi

Generate FFI bindings and data models from a single TypeScript source of truth.

Describe your API surface and data model once, in TypeScript. genffi extracts it and emits matching code across languages: a JSON Schema, Java DTOs, a Go data model, Go interfaces, a Go CGo shared-library wrapper (//exported C ABI), Go implementation stubs, a Java FFM caller, and TypeScript (Ajv) validators.

The architecture it is built for: implement a core in Go, compile it to a C shared library through the generated wrapper, and call into it from Java, Node or C — with every side's types staying in sync with the one TypeScript definition.

Status. The Go, Java and JSON-Schema targets are mature and load-bearing. The JS / N-API target is experimental and incomplete — see known limitations.

What it generates

Target Output Maturity
Go CGo wrapper package main with //exported C-ABI functions, C typedefs, handle tables ✅ the centerpiece
Go api / impl Go interface definitions, plus implementation skeletons to fill in ✅ stable
Go model Structs, interfaces, constructors with defaults, consts, enums, JSON marshalers ✅ stable
Shared library A Makefile linking the wrapper with -buildmode=c-shared, and a .gitignore ✅ stable
Java binding An FFM (Panama) caller: records, enums, handles, folds, futures, listeners ✅ compiled and run, needs Java 22+
Java model Records and interfaces with Jackson @JsonSubTypes polymorphism, Identifiers ✅ most complete data target
JSON Schema schema.json — the backbone of the model pipeline ✅ stable
TS validators Ajv-based validate.ts ✅ runs; ajv is yours to install
JS / N-API currently a copy of the Go CGo wrapper ❌ incomplete — do not use

Getting started

Everything you want generated lives under one of two namespaces: GenApi for behavior and GenModel for data. Anything else in the file is ignored.

// defs.ts
namespace GenApi {
  /** @singleton */
  export interface Stores {
    open: (cfg: StoreCfg) => Store;
    close: (store: Store) => void;
  }

  /** @handle */
  export interface Store {
    path: () => string;
    /** @throws */
    sync: (target: string) => Promise<string>;
  }

  export type StoreCfg = { src: string; readOnly: boolean };
}

Each interface declares its lifetime (@singleton, @handle, @fold, @listener); each method's tags declare how it fails and whether it blocks (@throws, @optional, @scoped, or a Promise<T> return for async). Point a config at the file and run it:

// genffi.config.ts
import type { Config } from "genffi";

export default {
  source: "./defs.ts",
  outDir: "./gen",

  schema: {},
  java: { pkg: "com.example.model", api: {} },
  go: {
    module: "github.com/example/blobstore",
    mod: {},
    model: {},
    api: {},
    cgo: {}, // the C shared-library wrapper
    impl: {}, // stubs to implement
  },
} satisfies Config;
npx genffi                      # reads ./genffi.config.ts
npx genffi --dry-run --verbose  # what would change, without writing

A target's presence in the config is what enables it — {} means "on, with the defaults" — and each one writes into gen/<key>. --strict is on by default in the CLI, so a problem with your input stops the run rather than generating from it.

From there: fill in the Go stubs, run make shared next to the generated wrapper, and call the library through the generated Java binding. The walkthrough with every step spelled out is getting started.

As a library

generate() is exported, and is the right call when the config has to be computed or when generation is one step of a larger script. Same object, two differences: source is required, and strict defaults to false so that a new rule can never break an existing build.

import { generate } from "genffi";

const { diagnostics, files } = generate({
  source: "./defs.ts",
  strict: true /* … */,
});

Two shapes of boundary

Both are conventions over the same pipeline — you choose by how you write your definitions.

  • Typed service. Model the surface as GenApi interfaces and let every method become its own C export, with structs, enums, handles and errors marshaled across the boundary. Reach for it when you control both sides and want the boundary itself typed and granular.
  • Command bus. Collapse the FFI surface to one function taking and returning a GenModel discriminated union carried as JSON. Reach for it when you want a minimal, stable ABI and would rather version the protocol than the C signatures.

Handles, fold chains, interface inheritance, async and callbacks compose on top of either. The full catalogue, with the example that shows each one, is use cases.

Examples

Five runnable examples, each owning exactly one story — indexed by examples/README.md:

npm run example            # typed service         → examples/gen
npm run example:command    # command bus           → examples/gen-command
npm run example:fold       # handles + folds       → examples/gen-fold  (committed)
npm run example:data       # model only, no FFI    → examples/gen-data
npm run example:callback   # listeners (callbacks) → examples/gen-callback

All of them run with strict: true, so they double as a calibration check on the rules.

Documentation

If you want to… Read
use genffi docs/guide/ — what it can do, and how to get it to do that
know the exact rules for your input GenApi · GenModel
bind the library from C, Node or Java memory ownership · Java binding
change genffi itself docs/design/ — architecture, decisions, invariants

docs/README.md is the full index.

Development

Requires Node >= 22.18 (engines.node; CI pins .nvmrc). A Go toolchain, make, a C compiler and a JDK 22+ are optional: the suites that need them skip when they are absent, so npm test works on a bare machine.

npm run build               # tsdown: ESM bundle + rolled-up d.ts
npm test                    # vitest
node --run test:compilers   # only the suites that compile and run generated code
npm run lint                # oxlint --fix, then oxfmt (writes)
npm run lint:check          # oxlint --type-aware + oxfmt --check (CI, read-only)
npm run type:check          # tsc --noEmit
npm run snap                # update snapshots

lefthook runs the fast gates before a commit and the slow ones before a push. Commit messages must be conventional commits, because semantic-release derives the version bump and the changelog from them — npm run cz walks you through a conforming message.

CONTRIBUTING.md has the rest: the repo layout, the hooks in detail, and what else to update when you change what genffi accepts or emits.

Known gaps

The JS/N-API target is the incomplete one. The accepted signature grammar is also narrow by design — (a: T, b: U) => R over basic and declared types, with anything else reported rather than emitted. See known limitations for the list with symptoms and workarounds.

License

MIT © Andreas Zahnen

About

Generate foreign function interfaces for Go, Node.js and Java

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages