Skip to content

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.

src/api/movie.ts
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 API
import { 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 bound gateway.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).

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.query is an alias for the one hand-written-Cypher path gateway.kg.query runs, 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 for CrmAccount. 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.

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.