Skip to content

Defining a contract

Every contract is built with defineContract(id, shape) from the pure @axion/bridgekit/contract entrypoint. The id is validated against /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/ — lowercase, dot-separated, <owner>.<surface>.

import { defineContract, t } from '@axion/bridgekit/contract';
export const ConnectHost = defineContract('connect.host', {
methods: { /* ... */ },
streams: { /* ... */ },
state: { /* ... */ },
});

The returned value is a frozen BridgeContract token. It carries a normalized descriptor, a hash (FNV-1a 32-bit over the normalized descriptor), and a phantom _shape type used only at compile time.

BridgeKit accepts two ways to write a contract. They compile to identical runtime descriptors — pick by taste and by what your codegen path supports.

Runtime, schema-driven. The schema also drives boundary encoding, so the wire format is derived from the same definition. Great when you want named, reusable object schemas.

import { defineContract, t } from '@axion/bridgekit/contract';
export const ConnectHost = defineContract('connect.host', {
methods: {
showLogin: t.fire(),
isLoggedIn: t.query(t.boolean()),
installEsim: t.query(
t.object({ url: t.string(), iccId: t.string() }),
t.literals('success', 'already-installed', 'cancelled', 'error'),
{ timeoutMs: null },
),
getDeviceInfo: t.querySync(t.object({ model: t.string(), osVersion: t.string() })),
trackEvent: t.fire(t.object({ name: t.string(), params: t.json() })),
},
streams: { otpCodes: t.stream(t.string()) },
state: { connectivity: t.state(t.object({ online: t.boolean() }), { online: false }) },
});

The marker ⇄ DSL mapping is one-to-one:

MarkerSchema DSLKind
Sync<P, R>()t.querySync(params, result)querySync
Async<P, R>()t.query(params, result)query
Void<P>()t.fire(params)fire
Stream<V, P>()t.stream(value, opts)stream
State<V>(initial)t.state(value, initial)state

A contract does not encode direction in its definition — direction is decided by which side provides. The same shape works both ways:

// provided by NATIVE, consumed by RN
export const ConnectHost = defineContract('connect.host', { /* ... */ });
// provided by RN, consumed by NATIVE — same shape, reverse direction
export const LiaFeature = defineContract('lia.feature', {
methods: { getUnreadCount: t.query(t.number()) },
state: { sessionStatus: t.state(t.literals('idle', 'active', 'error'), 'idle') },
});

@axion/bridgekit/contract is a pure, side-effect-free entrypoint: defineContract, the t namespace, the markers, and type utilities — nothing else. A contract file may import only this subpath and other contract files.

// ✅ allowed
import { defineContract, t } from '@axion/bridgekit/contract';
import { PickedFile } from './shared-schemas.contract';
// ❌ rejected by the CLI with file:line
import { analytics } from '../analytics';

The CLI walks the import graph before evaluating and fails with file:line on any violation. Why it matters: it lets the generator, Jest, and web builds import contracts safely — the transport-connecting side effect lives in the native runtime entry (index.native.ts), not in the contract module.

Object schemas can be hoisted, named, and reused across contracts. Inference helpers expose the derived TypeScript types:

const PickedFile = t.object({ uri: t.string(), mime: t.string() });
type Picked = t.Infer<typeof PickedFile>;
type Params = MethodParams<typeof ConnectHost, 'installEsim'>;
type Result = MethodResult<typeof ConnectHost, 'installEsim'>;
type Net = StateValue<typeof ConnectHost, 'connectivity'>;

Next: the full schema DSL, then methods, streams & state in depth.