Type methods — classes that extend Entity
A namespace function is a free function on a gateway surface. A type method is
a method on an in-scope object — movie.streaming({ country: "us" }), not a
bare gateway.movie.streaming(...). You author one by exporting a class that
extends Entity (from @embabel/runtime-types). Its fields are the type’s
shape; its async methods are the affordances.
import { Entity } from "@embabel/runtime-types";import type { StreamingShow } from "../types/movie";
/** The gateway ops this type calls: the type argument to `Entity`. */interface MovieGateway { streamingAvailability: { getShow(args: { id: string; country: string }): Promise<StreamingShow> };}
/** A film in the knowledge graph. Identity is `imdbId`. */export class Movie extends Entity<MovieGateway> { imdbId!: string; title?: string;
/** Where this movie is streaming in a country (ISO-3166 alpha-2, lowercase). */ async streaming(args: { country: string }): Promise<StreamingShow> { return this.gateway.streamingAvailability.getShow({ id: this.imdbId, country: args.country }); }}There is no ctx/self plumbing: this is the object the host hydrated from the
entity’s fields, and this.gateway is the injected context. Name the gateway once,
as Entity’s type argument: this.gateway is then typed with exactly the ops the
type calls, so every body and return type is real, with no cast. Without a type
argument the gateway is GenericGatewayContext, typed loosely; once sync generates
the host’s GatewayContext, Entity<GatewayContext> types every op the host offers.
Each method’s single parameter and return type drive the JSON Schema: a method that
needs one value takes it bare (comment(body: string)), one that needs several takes
one options object (bookCall(call: CallBooking)). The first JSDoc paragraph is the
LLM-visible description, exactly as for namespace functions.
Extending Entity is what makes the host recognise Movie as a type, and it
brings neighbors() for free — graph navigation (movie.neighbors({ hops }))
every type inherits with no per-type code. Entity is a normal class, so a realm
can introduce its own intermediate base to share behaviour across its types; the
manifest walks the whole base chain. Plain data the user writes that has no
behaviour of its own (e.g. MovieRating) stays a plain interface — promote it
to a class only when it grows methods.
Testing. entityForTest builds a real instance with its fields set and a
mock gateway injected — the same shape the host uses at runtime — so a method is
tested in milliseconds with no live server:
// tests/movie.test.ts — hermetic, no live APIimport { entityForTest, mockGateway } from "@embabel/runtime-types";import { Movie, type MovieGateway } from "../src/api/movie";
const getShow = vi.fn().mockResolvedValue({ streamingOptions: { au: [] } });const movie = entityForTest( Movie, { imdbId: "tt0113451" }, mockGateway<MovieGateway>({ streamingAvailability: { getShow } }),);
await movie.streaming({ country: "au" });expect(getShow).toHaveBeenCalledWith({ id: "tt0113451", country: "au" });Runtime. For class Movie extends Entity to instantiate in the sandbox, the
compiled handler must require("@embabel/runtime-types") for the base class.
embabel-build-manifest vendors the runtime’s CommonJS build into the realm’s
dist/node_modules/@embabel/runtime-types/, so the seeded handler bundle is
self-contained — you don’t manage this.
realm-movie is the worked example: a Movie class with streaming, details,
and rate plus inherited neighbors.
Type methods on virtual types — pure compute and effectful write-back
Section titled “Type methods on virtual types — pure compute and effectful write-back”A type method works the same whether the instance is a persisted entity or one
materialized on demand by a virtual join (a GitHubIssue, a HubSpotContact).
So a virtual type’s class gives its on-demand instances behaviour:
- pure methods compute over the instance’s own fields, no I/O (
issue.ageDays(),issue.needsTriage(),pr.isReadyForReview()); - effectful methods write back to the source through
this.gateway.<ns>.*(issue.close(),issue.addLabels('stale'),pr.requestReviewers('alice')), and may use boundgateway.cypher.query({cypher, params})when the host has an admitted receiver. External SQL uses approved producers or typed operations; raw SQL gateway access is not a portable guest capability.
A read materialises transient nodes and rolls them back; an effectful method commits
to the real source (the rollback never touches that side-effect).
realm-github is the worked example (GitHubIssue / GitHubPullRequest).
Acting on what a query finds
Section titled “Acting on what a query finds”The point of type methods is that code navigates the graph to what it needs and then works on it there, the way an object model does. In a script:
// 1. Read with gateway.cypher.query: every node in its rows carries __type and __labels,// virtual nodes included, so the host knows what each one is.const { rows } = await gateway.cypher.query({ cypher: ` MATCH (b:OdooBook)-[:HAS_CUSTOMER]->(c:OdooCustomer) WHERE c.name = 'Acme Corporation' RETURN c` });
// 2. Bind a row. state.set reads the row's labels, so state.get returns it with every// method its labels carry, from any realm. No type to name, no class to import.state.set("acme", rows[0].c);const acme = state.get("acme");
// 3. Work on it.await acme.addNote("Second failed payment this month; chasing.");await acme.scheduleFollowUp({ summary: "Call about the failed payments", due: "2026-10-15", kind: "call" });- Read through the Cypher gateway, under either name:
gateway.cypher.queryis an alias for the one hand-written-Cypher pathgateway.kg.queryruns, and both tag every returned node, so either read gives rows that know what they are. The alias reads better for the fixed, literal queries a program embeds. - Methods compose through labels. A node is the intersection of its labels, so a
vendor node that declares
parents: [CrmAccount]answers to its own type’s methods and to any method declared forCrmAccount.x.is(SomeType)narrows a row whose type is known only at runtime. - A program that imports the classes can hydrate instead:
hydrateByType(rows, { GitHubIssue }, gateway)returns typed instances.
Designing write methods
Section titled “Designing write methods”A type’s write methods are what an agent will do to a customer, a deal or a ticket, so design them for that caller:
- Name the action in the business’s words, and use the same name across realms. A
CRM realm’s customer offers
addNote,scheduleFollowUp,bookCall, whatever the vendor calls them, so a routine written against one stack reads the same against another. The method hides the vendor’s call shape (ids: [id], Odoo’s command lists, Chatwoot’s message types). - Make the safe outcome the default. A note is internal unless asked otherwise; a calendar booking sends no invitation unless asked; a message to the customer needs an explicit flag. Something that cannot be recalled happens only when the caller says so.
- Check before writing. Validate dates, ids and required fields in the method and throw with the reason; an exception before the call is better than a bad record after it. A graph id is a string; convert it to the source’s own id type in one checked place.
- Each method calls one declared verb, so what can change in the source is visible in
apis/with its effect metadata, and approving a method is approving that verb.

