Use a TypeScript file as an RPC spec.
@dldc/ts-api lets you define your API as a regular TypeScript file. The same
file is used on the server (to validate inputs/outputs and dispatch to
resolvers) and on the client (to get a fully type-safe function caller). No code
generation, no build step.
- Write a TypeScript file that defines your API (a tree of namespaces whose leaves are functions).
- On the server:
parsethe file, write resolvers, create an engine. - On the client:
querythe types and call functions — you get the return type back, typed and validated.
- Type-safe client without any build step — the client is a single Proxy that records the path and arguments. Types flow directly from your schema.
- Very thin client library — no runtime dependencies, just a Proxy.
- Input and output validation — arguments and return values are validated against valibot schemas generated from your types.
- Single source of truth — your TypeScript file is the spec. No separate schema language, no codegen.
This package is published on the JSR registry. Install it with:
deno add jsr:@dldc/ts-apiThis package is built for Deno and the JSR registry. To run the examples or tests, use Deno. For Deno-specific setup, see deno.com.
Create a TypeScript file that describes your API. This file is the single source of truth — it is used by both the server and the client.
// api/graph.ts
export interface User {
id: string;
name: string;
}
export interface Graph {
// Top-level namespace: organize functions with interfaces/objects
users: {
// Each leaf is a function — this is an RPC endpoint
list: () => User[];
byId: (id: string) => User;
create: (name: string) => User;
};
version: () => string;
}Create a file that maps all the types you want to use in the graph. This is what
you pass to parse (server) and query (client) for type safety.
// api/types.ts
import type { Graph, User } from "./graph.ts";
export interface AllTypes {
Graph: Graph;
User: User;
}// server.ts
import { resolve } from "@std/path";
import { createEngine, fn, parse } from "@dldc/ts-api/server";
import type { AllTypes } from "./api/types.ts";
const graph = parse<AllTypes>(resolve("./api/graph.ts"));
export const engine = createEngine({
graph,
entry: "Graph", // the root interface name in your graph file
resolvers: [
fn(graph.Graph.users.list, () => {
return [
{ id: "1", name: "Alice" },
{ id: "2", name: "Bob" },
];
}),
fn(graph.Graph.users.byId, (_ctx, [id]) => {
// args are typed as [string] from the graph definition
return { id, name: "Alice" };
}),
fn(graph.Graph.version, () => "1.0.0"),
],
});// client.ts
import { query, queryToObject, type TQuery } from "@dldc/ts-api/client";
import type { AllTypes } from "./api/types.ts";
const q = query<AllTypes>();
// A helper to send queries to the server
async function executeQuery<R>(query: TQuery<R>): Promise<R> {
const { path, args } = queryToObject(query);
const res = await fetch("/api", {
method: "POST",
body: JSON.stringify({ path, args }),
});
return await res.json();
}
// Now you can call your API with full type safety:
const version = await executeQuery(q.Graph.version()); // string
const users = await executeQuery(q.Graph.users.list()); // User[]
const user = await executeQuery(q.Graph.users.byId("1")); // UserA ts-api schema is a TypeScript file containing only interface and type
declarations. One interface (the "entry") serves as the root of your API tree.
The leaves of the tree are functions — each function is an RPC endpoint.
Graph (interface, the entry)
├── users (interface — a namespace)
│ ├── list: () => User[] ← endpoint
│ ├── byId: (id) => User ← endpoint
│ └── create: (name) => User ← endpoint
└── version: () => string ← endpoint
Rules:
-
Top-level declarations must be
interfaceortypealiases. -
Function return types must not contain functions. This means you cannot return a namespace or a callable. If a function returns an object, every property of that object must be data (string, number, array, nested object, etc.). To expose nested operations, use a namespace property instead:
// ❌ Not allowed — return type contains a function interface Graph { user: (id: string) => { rename: (name: string) => User }; } // ✅ Allowed — namespace with functions interface Graph { users: { rename: (id: string, name: string) => User; }; }
-
Namespace properties must be functions or sub-namespaces. A bare data field like
version: stringis not reachable as an endpoint. Use a function instead:// ❌ Not reachable — bare data field on a namespace interface Graph { version: string; } // ✅ Reachable — a function returning data interface Graph { version: () => string; }
Note: ts-api does not support all TypeScript syntax. Only a subset of types is supported (see Supported types for the full list). If you need a syntax that isn't supported, please open an issue.
Generic interfaces and type aliases are supported. The type parameter is
inferred from usage. For example, a Paginated<T> wrapper can be used both as a
return type and as an argument type:
// api/graph.ts
export interface Paginated<T> {
data: T[];
total: number;
}
export interface TodoItem {
name: string;
done: boolean;
}
export interface ListParams<T> {
filter?: T;
page?: number;
}
export interface Graph {
// Paginated<TodoItem> as a return type
todos: () => Paginated<TodoItem>;
// Paginated<TodoItem> as an input type
createMany: (items: Paginated<TodoItem>) => TodoItem[];
// Generic input with inferred type parameter
search: (params: ListParams<string>) => TodoItem[];
}On the server, resolvers receive and return the concrete instantiation:
import { fn } from "@dldc/ts-api/server";
fn(graph.Graph.todos, () => ({
total: 1,
data: [{ name: "Buy milk", done: false }],
}));
fn(graph.Graph.createMany, (_ctx, [items]) => {
// items is typed as Paginated<TodoItem>
return items.data; // TodoItem[]
});
fn(graph.Graph.search, (_ctx, [params]) => {
// params is typed as ListParams<string>
return [{ name: params.filter ?? "", done: false }];
});When you call a function on the client, it produces a TQuery<R> object
containing:
path: an array of strings describing the navigation to the function (e.g.["Graph", "users", "byId"])args: an array of arguments passed to the function (e.g.["1"])RESULT(phantom type): the return typeR, for type inference
The queryToObject helper extracts { path, args } so you can serialize and
send them to the server.
createEngine takes:
graph— the result ofparseentry— the name of the root interface (e.g."Graph")resolvers— an array of resolvers
When engine.run({ path, args }) is called:
- It validates that
path[0]matches the entry. - It navigates the graph along
path, collecting resolvers attached to each node. - It validates
argsagainst the function's argument types (via valibot). - It runs the composed middleware chain (resolvers).
- It validates the return value against the function's return type.
- It returns the validated value.
Resolvers are functions attached to nodes in the graph. There are two kinds:
fn— a simple resolver that receives typed args and returns a value.resolver— a middleware-style resolver with access tonextfor wrapping/composing.
Both use the @dldc/stack context system for dependency injection and shared
state.
Use fn for simple resolvers. The second argument is the typed args tuple
(inferred from the graph node):
import { fn } from "@dldc/ts-api/server";
const listUsers = fn(
graph.Graph.users.list, // attach to this node
() => {
return db.listUsers(); // just return the value
},
);
const byId = fn(
graph.Graph.users.byId,
(_ctx, [id]) => {
// args is typed as [string] from the graph definition
return db.findUser(id);
},
);Use resolver when you need middleware capabilities (logging, auth, wrapping):
import { resolver } from "@dldc/ts-api/server";
const listUsers = resolver(
graph.Graph.users.list,
(ctx, next) => {
console.log("before");
const value = await next(ctx); // value from downstream
console.log("after");
return value;
},
);Resolvers attached to parent namespaces run before child resolvers. You can use
next(ctx) to delegate to the next resolver in the chain — it returns the value
from downstream:
resolver(graph.Graph, (ctx, next) => {
console.log("before");
const value = await next(ctx);
console.log("after");
return value;
});Multiple middlewares can be attached to the same node — they run in order. The last one should return a value:
resolver(
graph.Graph.apps.all,
(ctx, next) => {
console.log("first");
return next(ctx);
},
(ctx, next) => {
console.log("second");
return next(ctx);
},
() => {
return []; // the final value
},
);Use @dldc/stack keys to share data between resolvers via the context. A common
pattern: a namespace resolver loads data once, and child function resolvers read
it from context instead of refetching:
import { createKey, fn, resolver } from "@dldc/ts-api/server";
const AppKey = createKey<{ name: string; version: string }>("App");
// Namespace resolver: load the app config once and share it with children
resolver(graph.Graph.apps, (ctx, next) => {
const app = db.getApp(); // e.g. { name: "TodoApp", version: "2.0" }
return next(ctx.with(AppKey.Provider(app)));
});
// Function resolver: read the app config from context instead of refetching
fn(graph.Graph.apps.byId, (ctx, [id]) => {
const app = ctx.getOrFail(AppKey.Consumer);
const todos = db.getTodos(id);
return { appName: app.name, todos };
});The engine.run function accepts an optional second argument — a function that
can extend the context before resolvers run. This is how you inject
request-scoped data like the authenticated user, request ID, etc.
import { createEngine, createKey, parse } from "@dldc/ts-api/server";
// 1. Define a key for the auth data
const AuthKey = createKey<{ id: string; name: string } | null>("auth");
const engine = createEngine({
graph,
entry: "Graph",
resolvers: [
// Guard: reject unauthenticated requests on the `users` namespace
resolver(graph.Graph.users, (ctx, next) => {
const user = ctx.getOrFail(AuthKey.Consumer);
if (!user) throw new Error("Unauthorized");
return next(ctx);
}),
// Use the auth data in a resolver
fn(graph.Graph.auth, (ctx) => {
const user = ctx.getOrFail(AuthKey.Consumer);
return user;
}),
],
});
// 2. When running a query, provide the context
const result = await engine.run(
{ path, args },
(ctx) => ctx.with(AuthKey.Provider(currentUser)),
);In a typical HTTP server:
async function handler(req: Request): Promise<Response> {
const { path, args } = await req.json();
const user = await getUserFromRequest(req); // your auth logic
const result = await engine.run(
{ path, args },
(ctx) => ctx.with(AuthKey.Provider(user)),
);
return Response.json(result);
}ts-api parses your graph file (the entry .ts file) to build its schema. It
only reads that one file — it does not resolve imports or global types. This
means any type that isn't an interface or type declared directly in the
graph file needs a builtin to tell ts-api how to validate it at runtime.
There are two common scenarios:
- Global types like
Date— ts-api seesDatein the graph file but can't introspect its structure (it's a global, not an interface in the file). - Imported types — if you
import type { PlainDate } from "./builtins.ts", ts-api won't follow the import. It just sees the namePlainDateand needs a builtin to know how to validate it.
A builtin provides a valibot schema for runtime validation. The type itself is opaque to ts-api — it's treated as a leaf value, not introspected.
Important: ts-api does not handle encoding or decoding (transport). It validates values at runtime on both the client side (arguments) and the server side (arguments and return values), but it does not serialize or deserialize them. If your API only uses JSON-compatible types (
string,number,boolean,null, arrays, plain objects), you don't need to worry about this. If you use non-JSON types likeDateorTemporal.PlainDate, you are responsible for encoding/decoding them on the wire. See Transport and encoding below.
Date is a common global type, so ts-api ships with a builtin for it included
by default — no extra setup needed:
// api/graph.ts
export interface Graph {
now: () => Date;
formatDate: (date: Date) => string;
}For any other type ts-api can't introspect (imported types, globals, or opaque type aliases), you create a custom builtin. The builtin provides a valibot schema used to validate the value at runtime.
ts-api matches builtins by name. Both simple identifiers (Date, PlainDate)
and qualified names (Temporal.PlainDate) are supported. This means you can use
Temporal.PlainDate directly in your graph — no type alias needed:
// api/builtins.ts
import { builtin } from "@dldc/ts-api/server";
import * as v from "@valibot/valibot";
export const PlainDateBuiltin = builtin<Temporal.PlainDate>({
// valibot schema used to validate the value at runtime
getSchema: () => v.instance(Temporal.PlainDate),
});Register the builtin on the server side — the key must match the name used in the graph:
// server.ts
import { createBuiltins, DEFAULT_BUILTINS, parse } from "@dldc/ts-api/server";
import { PlainDateBuiltin } from "./api/builtins.ts";
const builtins = createBuiltins({
...DEFAULT_BUILTINS,
"Temporal.PlainDate": PlainDateBuiltin,
});
const graph = parse<AllTypes>(resolve("./api/graph.ts"), builtins);Then use the type directly in your graph:
// api/graph.ts
export interface Graph {
birthday: () => Temporal.PlainDate;
eventsOn: (date: Temporal.PlainDate) => string[];
}| TypeScript construct | Supported | Notes |
|---|---|---|
string, number, boolean |
✅ | Primitives |
null |
✅ | Literal null |
String literals ("admin" | "user") |
✅ | Unions of string literals |
| Number/boolean literals | ✅ | |
Arrays (T[]) |
✅ | |
Nullable (T | null) |
✅ | |
Objects ({ foo: string }) |
✅ | Inline type literals |
| Interfaces | ✅ | Named, reusable |
| Type aliases | ✅ | Including unions |
| References to other interfaces | ✅ | ref: OtherInterface |
| Generics | ✅ | interface Paginated<T> { data: T[] } |
Optional properties (foo?: string) |
✅ | |
Functions (arg: T) => R |
✅ | RPC endpoints |
| Function return types containing functions | ❌ | Rejected at parse time |
undefined |
❌ | Use null instead |
void |
❌ | Use null instead |
| Methods on interfaces | ❌ | Use prop: () => T instead |
Creates a type-safe proxy to build queries.
const q = query<AllTypes>();
const userQuery = q.Graph.users.byId("1"); // TQuery<User>Extracts { path, args } from a query for serialization.
const { path, args } = queryToObject(q.Graph.users.byId("1"));
// path: ["Graph", "users", "byId"]
// args: ["1"]TQuery<R>— a finalized query with return typeR.TQueryRequest—{ path: string[]; args: unknown[] }, the extracted query data, ready for serialization.TQueryOf<T>— maps a typeTto its query proxy type (for advanced use).
Parses a TypeScript file into a graph object.
const graph = parse<AllTypes>(resolve("./api/graph.ts"));schemaPath: path to your.tsschema file.builtins(optional): a builtins graph fromcreateBuiltins. Defaults toDEFAULT_BUILTINS_GRAPH(includesDate).
Creates an engine to run queries.
const engine = createEngine({
graph,
entry: "Graph",
resolvers: [...],
});Returns { graph, run } where run({ path, args }) executes the query and
returns the validated result.
Attaches a simple resolver. The resolver receives (ctx, args) where args is
typed from the graph node's function parameters.
fn(graph.Graph.users.byId, (_ctx, [id]) => {
return db.findUser(id);
});The resolver returns the value (or a promise of it).
Attaches middleware to a graph node. Use this when you need next for
wrapping/composing.
resolver(graph.Graph.users.list, (ctx, next) => {
console.log("before");
return next(ctx);
});Middleware returns the value directly (not a context). next(ctx) returns the
value from downstream middleware.
The context object passed to resolvers. Extends @dldc/stack's Stack.
Key methods:
ctx.getInputOrFail(graph)— returns the validated arguments, typed to the function's parameter types.ctx.get(key.Consumer)/ctx.getOrFail(key.Consumer)— reads a value from the stack (for shared state between resolvers).ctx.with(key.Provider(value))— sets a value in the stack.
Creates a typed key for sharing data between resolvers via the stack.
Re-exported from @dldc/stack.
Creates a builtins graph from a config object.
const builtins = createBuiltins({
...DEFAULT_BUILTINS,
MyType: builtin<MyType>({ getSchema: () => v.string() }),
});Helper to define a builtin type.
builtin<Date>({ getSchema: () => v.date() });ts-api uses @dldc/erreur for error handling. Errors are categorized:
- Client errors (
GraphClientErreur) — caused by the query, safe to send back to the client:ArgsValidationFailed— arguments didn't match the schema.InvalidEntry— the query didn't start from the entry point.
- Server errors (
GraphServerErreur) — caused by the server implementation, should be logged:InvalidResolvedValue— a resolver returned a value that didn't match the return type.
To inspect an error's data:
import { GraphClientErreur } from "@dldc/ts-api/server";
try {
await engine.run({ path, args });
} catch (err) {
const data = GraphClientErreur.read(err);
if (data?.kind === "ArgsValidationFailed") {
// data.issues — valibot issues
}
}ts-api is transport-agnostic and does not handle encoding or decoding. The
client produces a TQuery<R> which you turn into a { path, args } object with
queryToObject and send however you like (fetch, WebSocket, etc.). The server's
engine.run accepts { path, args } directly and returns a plain value.
Security: ts-api validates the structure of incoming
argsagainst your schema, but it does not impose limits on payload size, request rate, or path length. You are responsible for enforcing body size limits, rate limiting, and authentication at the transport layer (e.g., in your HTTP server middleware) before callingengine.run.
If you only use string, number, boolean, null, arrays, and plain
objects, you can use JSON.stringify / JSON.parse directly:
// Client side
async function executeQuery<R>(query: TQuery<R>): Promise<R> {
const { path, args } = queryToObject(query);
const res = await fetch("/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, args }),
});
if (!res.ok) throw new Error(await res.text());
return await res.json();
}
// Server side
async function handler(req: Request): Promise<Response> {
const { path, args } = await req.json();
const result = await engine.run({ path, args });
return Response.json(result);
}ts-api validates values at runtime (via valibot schemas), but it does not
serialize them. If you use types like Date or Temporal.PlainDate, you must
handle encoding/decoding yourself on both sides of the wire.
A common solution is to use
superjson, which extends JSON to
support Date, Map, Set, BigInt, URL, and more. It transparently
encodes/decodes these types so they survive transport:
import SuperJSON from "superjson";
// Client side
async function executeQuery<R>(query: TQuery<R>): Promise<R> {
const { path, args } = queryToObject(query);
const res = await fetch("/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: SuperJSON.stringify({ path, args }),
});
if (!res.ok) throw new Error(await res.text());
return SuperJSON.parse<R>(await res.text());
}
// Server side
async function handler(req: Request): Promise<Response> {
const { path, args } = SuperJSON.parse(await req.text());
const result = await engine.run({ path, args });
return new Response(SuperJSON.stringify(result), {
headers: { "Content-Type": "application/json" },
});
}With superjson, a Date value is transparently encoded as
{ json: "2024-01-15T...", meta: { values: { ... } } } on the wire, and decoded
back to a Date instance on the other side. The valibot schemas generated by
ts-api will then validate the decoded Date instance as expected.
Look at the examples/family-planner directory for a complete example. You can
run it with:
deno task example:family-plannerYou can also look at the tests directory to see all supported features.