Skip to content

Defining a contract

Every contract is built with defineContract(id, shape) from the pure @malopezr7/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 '@malopezr7/bridgekit/contract';
export const AppHost = defineContract('app.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 '@malopezr7/bridgekit/contract';
export const AppHost = defineContract('app.host', {
methods: {
showLogin: t.fire(),
isLoggedIn: t.query(t.boolean()),
saveFile: t.query(
t.object({ url: t.string(), name: t.string() }),
t.literals('success', 'already-exists', '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: { notifications: 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 AppHost = defineContract('app.host', { /* ... */ });
// provided by RN, consumed by NATIVE — same shape, reverse direction
export const ExampleFeature = defineContract('example.feature', {
methods: { getUnreadCount: t.query(t.number()) },
state: { sessionStatus: t.state(t.literals('idle', 'active', 'error'), 'idle') },
});

@malopezr7/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 '@malopezr7/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 AppHost, 'saveFile'>;
type Result = MethodResult<typeof AppHost, 'saveFile'>;
type Net = StateValue<typeof AppHost, 'connectivity'>;

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