Interfaces
What Is an Interface
An interface is a contract that defines how modules communicate. The contract declares functions, events, or registrations without implementing them. Producers fulfill the contract, and consumers call the contract — neither side knows about the other directly.
The AntelopeJS core resolves which module fulfills each interface at runtime. A module that needs email delivery imports the email interface, not the email module. Modules depend on contracts rather than on each other, which keeps them independent and interchangeable.
What an Interface Contains
An interface file is more than type definitions and proxy declarations. The file serves as the complete public surface that both producers and consumers rely on.
An interface can contain:
- Type definitions — TypeScript types and interfaces shared between producer and consumer
- Proxy instances —
InterfaceFunction,EventProxy, andRegisteringProxydeclarations that define the contract points - Wrapper functions — Public API functions that call the underlying proxies, providing a cleaner API surface
- Decorator factories — Decorators used by consumers (e.g.,
@Controller,@Authentication) - Utility code — Helper classes, query builders, metadata management — any generic code that stays the same regardless of the implementation
The following example shows an interface file with shared types, internal proxies, and public wrapper functions:
import { InterfaceFunction } from "@antelopejs/interface-core";
// Types shared between producer and consumer
export interface EmailParams {
to: string;
subject: string;
body: string;
}
export interface EmailResult {
messageId: string;
status: string;
}
// Internal proxies — the actual contract points
export namespace internal {
export const sendEmailProxy =
InterfaceFunction<(params: EmailParams) => Promise<EmailResult>>();
export const getStatusProxy =
InterfaceFunction<(messageId: string) => Promise<string>>();
}
// Public API — clean wrapper functions for consumers
export function SendEmail(params: EmailParams): Promise<EmailResult> {
return internal.sendEmailProxy(params);
}
export function GetEmailStatus(messageId: string): Promise<string> {
return internal.getStatusProxy(messageId);
}
Consumers call SendEmail(params) instead of reaching for the proxy directly. The wrapper functions provide a stable, readable API while the proxies handle the runtime resolution behind the scenes.
Embedded vs Standalone Interfaces
Interfaces take one of two forms depending on how many modules need to implement them.
Embedded Interface
An embedded interface lives inside the module that implements the contract, in a src/interface/ directory. The module contains both the contract and its implementation. Embedded interfaces are the natural starting point when you create a new module with a new interface.
my-module/
├── src/
│ ├── index.ts
│ ├── interface/
│ │ └── index.ts # Interface contract
│ └── implementations/
│ └── index.ts # Implementation
The module entry point connects the contract to its implementation:
import { ImplementInterface } from "@antelopejs/interface-core";
export async function construct() {
ImplementInterface(
await import("./interface"),
await import("./implementations"),
);
}
Standalone Interface Package
When an interface needs multiple implementations, extract the contract into its own npm package. The standalone interface package contains only the contract — types, proxies, wrappers, and utilities. Each implementing module lives in a separate package and declares the interface in its implements field.
@antelopejs/interface-email/ # Standalone interface package
├── src/
│ └── index.ts # Types, proxies, wrappers
@antelopejs/email-brevo/ # Implementation module A
├── src/
│ ├── index.ts
│ └── implementations/
│ └── email/
│ └── index.ts
@antelopejs/email-sendgrid/ # Implementation module B
├── src/
│ ├── index.ts
│ └── implementations/
│ └── email/
│ └── index.ts
Each implementing module references the external interface package in its configuration:
{
"antelopeJs": {
"implements": ["@antelopejs/interface-email"]
}
}
The module entry point imports the interface from the standalone package instead of a local path:
import { ImplementInterface } from "@antelopejs/interface-core";
export async function construct() {
ImplementInterface(
await import("@antelopejs/interface-email"),
await import("./implementations/email"),
);
}
How the Core Tells the Two Kinds Apart
Both an interface package and a runtime module carry an antelopeJs key in
their package.json, so the key alone says nothing. The discriminator is
antelopeJs.implements:
antelopeJs.implements | The package is | Depending on it means |
|---|---|---|
absent, or [] | an interface package | the contract must be provided by some loaded module |
| lists other packages | a runtime module | a plain package dependency |
| lists only the package itself | a runtime module that ships its own interface | the contract must be provided |
This matters because the core refuses to start when a module depends on an
interface package nobody implements. Without the rule, a module that depends
on another runtime module — a playground depending on the product it
exercises, say — fails startup with
Unresolved interface dependencies: <module> requires <runtime package>, and
the only way out is to make the runtime package declare itself in its own
implements. Declare implements for what a module actually implements; a
package never has to name itself to be depended upon.
A package loaded as a module of the project is never treated as an interface package, whatever its manifest says.
When to Extract
Start with an embedded interface and extract to a standalone package when the situation calls for it. Three signals indicate that extraction makes sense:
- A second module needs to implement the same interface
- Consumers should depend on the contract without depending on a specific implementation
- The interface becomes part of the public ecosystem and needs its own release cycle
Complete Example
The following example walks through a storage interface from declaration to consumption. It covers three files: the interface contract, the producer module that implements it, and a consumer module that uses it.
Step 1 — Declare the interface
The interface defines a key-value storage contract with get and set operations. InterfaceFunction creates typed proxies, and wrapper functions provide the public API.
import { InterfaceFunction } from "@antelopejs/interface-core";
// Shared types
export interface StorageEntry {
key: string;
value: string;
updatedAt: number;
}
// Internal proxies — the contract points
export namespace internal {
export const getProxy =
InterfaceFunction<(key: string) => Promise<StorageEntry | null>>();
export const setProxy =
InterfaceFunction<(key: string, value: string) => Promise<StorageEntry>>();
}
// Public API — consumers call these functions
export function Get(key: string): Promise<StorageEntry | null> {
return internal.getProxy(key);
}
export function Set(key: string, value: string): Promise<StorageEntry> {
return internal.setProxy(key, value);
}
Step 2 — Implement in the producer module
The producer module exports a namespace that matches the interface's internal namespace. The core binds each proxy to the corresponding function by matching names.
import type { StorageEntry } from "../interface";
const store = new Map<string, StorageEntry>();
export namespace internal {
export async function getProxy(key: string): Promise<StorageEntry | null> {
return store.get(key) ?? null;
}
export async function setProxy(
key: string,
value: string,
): Promise<StorageEntry> {
const entry: StorageEntry = { key, value, updatedAt: Date.now() };
store.set(key, entry);
return entry;
}
}
The module entry point connects the interface to its implementation during the construct phase.
import { ImplementInterface } from "@antelopejs/interface-core";
export async function construct() {
ImplementInterface(
await import("./interface"),
await import("./implementations"),
);
}
Step 3 — Consume from another module
A consumer module imports the wrapper functions from the interface and calls them directly. The consumer has no dependency on the producer module — only on the contract.
import { Get, Set } from "@your-org/storage-module/interface";
export async function start() {
await Set("greeting", "hello world");
const entry = await Get("greeting");
if (entry) {
console.log(entry.value); // "hello world"
}
}
If the proxy has no implementation attached yet when the consumer calls Get or Set, the call queues automatically and resolves once a producer module attaches its implementation. This queuing behavior means module load order does not matter.
Interface Instances
The core evaluates an interface package once for each provider it is bound to. A module bound to mongodb-module and a module bound to postgres-module each import their own copy of @antelopejs/interface-database; modules bound the same way share one copy, so a project with one provider per interface has one copy of each, as if nothing happened.
Each copy's proxies belong to exactly one provider. Objects built from a copy (a query, a schema) carry that copy's proxies, so they reach that provider wherever they end up. A standalone interface built on others is copied the same way, once per combination of bindings it is built on. See Interface Bindings for how a module is bound.
Two instances of one module package, configured apart, are also evaluated separately, dependencies included, so neither sees the other's module-level state.
See also
- Proxies — How proxy types handle inter-module communication
- Exporting Interfaces — How to export interfaces from your module