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.
parse is pure: it takes the schema source text and does no file-system
access, so it runs anywhere — including the browser and edge runtimes. The only
permission it needs from the environment is for the parser it imports
(@lezer/lr), which reads a single environment variable at load time:
deno run --allow-env=LOG server.ts--allow-env=LOG—parseimports a lightweight syntax parser that reads theLOGenvironment variable at module load time (used for optional debug logging). Scoping it to justLOGis sufficient; it never touches any other environment variable.
On the server, you are responsible for loading the schema file (either by
reading it yourself, or with parseFromFile from
@dldc/ts-api/server/filesystem), so your app also needs read access to it:
deno run --allow-env=LOG --allow-read=path/to/schema.ts server.tsThe rest of the library — query on the client, createEngine/resolvers on the
server — is pure and needs no permissions at all.
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/schema.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;
}// server.ts
import { resolve } from "@std/path";
import { parseFromFile } from "@dldc/ts-api/server/filesystem";
import { createEngine, fn } from "@dldc/ts-api/server";
import type { Graph } from "./api/schema.ts";
const graph = parseFromFile<{ Graph: Graph }>(resolve("./api/schema.ts"));
export const engine = createEngine({
graph,
entry: "Graph", // the root interface name in your schema 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 { Graph } from "./api/schema.ts";
const q = query<{ Graph: Graph }>();
// 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/schema.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 }];
});A generic type alias is resolved through all its usages in the graph. You can exploit this to attach a middleware to a whole category of endpoints at once, instead of repeating it on every node. This is useful for e.g. authorization ("admin-only" operations).
Define a generic "marker" type and wrap the endpoints you want to protect:
// api/schema.ts
export type Admin<T> = T;
export interface Graph {
users: {
create: () => null;
// object form — a namespace of admin-only operations
admin: Admin<{ delete: () => null }>;
};
posts: {
create: () => null;
admin: Admin<{ delete: () => null }>;
};
files: {
// function form — a single admin-only endpoint
admin: Admin<() => null>;
};
}Note: endpoint return types cannot be
void— usenullinstead (see Supported types).
Then attach a single resolver to the generic type itself. It runs before every
endpoint wrapped in Admin<...>, anywhere in the graph:
// server.ts
resolver(
graph.Admin,
(ctx, next) => {
if (currentUser().role !== "admin") {
throw new Error("Forbidden: admin only");
}
// `next` requires the context — call `next(ctx)`, not `next()`
return next(ctx);
},
);The generic middleware wraps the endpoints' own resolvers, and namespace middlewares wrap them in turn:
namespace → graph.Admin guard → endpoint fn
Because you reference graph.Admin directly (instead of navigating from the
Graph entry), Admin must be added to the type map — see
Type maps (advanced):
const graph = parseFromFile<{ Graph: Graph; Admin: Admin<any> }>(
resolve("./api/schema.ts"),
);By default you pass the entry type to parse and query directly, as
{ Graph: Graph } above. Everything reachable from the entry is typed
structurally, so the resolvers and the client get full type safety from the
entry alone — you never have to list every type of your schema.
You only need a bigger type map when a resolver references a top-level type
directly, outside of the Graph namespace. For example
resolver(graph.Admin, ...) (see
Generic wrapper middleware)
must list Admin:
// server.ts
import { resolve } from "@std/path";
import { parseFromFile } from "@dldc/ts-api/server/filesystem";
import type { Admin, Graph } from "./api/schema.ts";
const graph = parseFromFile<{ Graph: Graph; Admin: Admin<any> }>(
resolve("./api/schema.ts"),
);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 resolversvalidateOutput(optional, defaulttrue) — whether to validate the returned value of endpoints against their schema. Set tofalseto skip this validation and return the resolver's value as-is.
It returns an engine exposing:
run({ path, args })— executes the query and returns the validated resultgraph— the graph it was created from
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 (unless
validateOutput: false). - It returns the 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 }>("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);
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 the source of your schema (the entry .ts file) to build its
schema. It only works with 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 schema 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 schema 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/schema.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";
import type { Graph } from "./api/schema.ts";
const builtins = createBuiltins({
...DEFAULT_BUILTINS,
"Temporal.PlainDate": PlainDateBuiltin,
});
const graph = parseFromFile<{ Graph: Graph }>(resolve("./api/schema.ts"), {
builtins,
});Then use the type directly in your graph:
// api/schema.ts
export interface Graph {
birthday: () => Temporal.PlainDate;
eventsOn: (date: Temporal.PlainDate) => string[];
}If a type referenced in the schema file is neither declared in the file nor
registered as a builtin, parse fails fast by default. This catches typos,
forgotten imports, or undeclared global types before any query runs:
// api/schema.ts
import type { PlainDate } from "./shared.ts"; // never registered as a builtin
export interface Graph {
birthday: () => PlainDate; // → parse throws
}parseFromFile<{ Graph: Graph }>(resolve("./api/schema.ts"));
// Error: Missing builtin type: PlainDate. Register them with createBuiltins()
// or set missingBuiltinAction to 'warn' or 'ignore'.You can opt out with the missingBuiltinAction option, which accepts either a
single action or an object with separate actions for inputs and outputs:
"throw"(default) — throw atparsetime."warn"— auto-register the missing type as a builtin (with anunknownschema) and log a warning to the console."ignore"— auto-register the missing type as a builtin (with anunknownschema) without logging.
const graph = parseFromFile<{ Graph: Graph }>(
resolve("./api/schema.ts"),
{ missingBuiltinAction: "warn" }, // auto-register + warn
);To apply different rules for types used as function arguments vs return values,
pass an object { input, output }:
const graph = parseFromFile<{ Graph: Graph }>(
resolve("./api/schema.ts"),
{
missingBuiltinAction: {
input: "throw", // fail on unknown types received from clients
output: "warn", // but auto-register unknown return types
},
},
);- A missing type used only as an input (function argument) uses the
inputaction. - A missing type used only as an output (function return value) uses the
outputaction. - A missing type used on both sides uses the stricter of the two (
throw>warn>ignore).
Auto-registered builtins are treated as opaque leaf values validated against an
unknown schema (anything passes). They are added to the graph's root
structure, so you can inspect them via getStructure:
import { getStructure } from "@dldc/ts-api/server";
console.log(getStructure(graph).builtins.map((b) => b.name));
// ["Date", "PlainDate"]Security warning: Auto-registering a type used as an input (function argument) means its values will be validated against an
unknownschema, which accepts anything. Incoming arguments of that type will not be checked at all, so arbitrary/unexpected data can reach your resolvers. Only opt intomissingBuiltinAction: "warn"/"ignore"for inputs when you are certain it is safe, and prefer declaring a real builtin (with a proper valibot schema) or keeping"throw"for client-supplied inputs. This risk does not apply to outputs (return values), which are produced by your own resolvers.
| 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<{ Graph: Graph }>();
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 the source text of a TypeScript schema into a graph object. parse
is pure — it does no file-system access, so it works anywhere: Deno, Node, the
browser, or edge runtimes. Fails fast by default if the schema references a type
that is neither declared nor registered as a builtin.
import { parse } from "@dldc/ts-api/server";
// Browser/edge: fetch or import the schema source
const graph = parse<{ Graph: Graph }>(await (await fetch(schemaUrl)).text());
// Deno/Node: load the file yourself, then pass the source
const graph = parse<{ Graph: Graph }>(
await Deno.readTextFile("./api/schema.ts"),
);source: the TypeScript source of your schema (a string).options(optional): an object with:builtins— a builtins graph fromcreateBuiltins. Defaults toDEFAULT_BUILTINS_GRAPH(includesDate).missingBuiltinAction— a single"throw"(default),"warn", or"ignore", or an object{ input, output }to use different actions for function arguments vs return values. Controls how types referenced in the schema but neither declared nor registered as builtins are handled. See Missing builtins.
On Deno/Node you can also use the filesystem convenience variant
(@dldc/ts-api/server/filesystem, not browser-safe) to read the file for you:
parseFromFile(path, options?) behaves like the old path-based parse, and
readSchemaFile(path) returns the file contents as a string.
import { parseFromFile } from "@dldc/ts-api/server/filesystem";
const graph = parseFromFile<{ Graph: Graph }>(resolve("./api/schema.ts"));Creates an engine to run queries.
const engine = createEngine({
graph,
entry: "Graph",
resolvers: [...],
});Options:
graph— the graph returned byparse.entry— the name of the root interface (e.g."Graph").resolvers— an array of resolvers.validateOutput(optional, defaulttrue) — whenfalse, skips the schema validation of endpoint return values and returns them as-is.
Returns { graph, run } where:
graph— the graph the engine was created from.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). The return type is checked against the graph node's expected output at compile time.
Note on literals: because the expected output flows through a generic type, TypeScript widens string/number literals. When a function returns a union of literals (enums, literal types), annotate the returned literal with
as constor the expected type to keep it valid:
fn(graph.Graph.role, () => "admin" as const);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() });Extracts the raw parsed structure from a graph returned by parse. This gives
full access to the internal parse tree — all top-level interfaces, type aliases,
their properties, and registered builtins. Use this when you need low-level
access that extractApi does not provide.
import { getStructure } from "@dldc/ts-api/server";
import { parseFromFile } from "@dldc/ts-api/server/filesystem";
const graph = parseFromFile<{ Graph: Graph }>(resolve("./api/schema.ts"));
const structure = getStructure(graph);
// List all declared types
console.log(structure.types.map((t) => t.name));
// ["User", "Graph", ...]
// Inspect builtins
console.log(structure.builtins.map((b) => b.name));
// ["Date"]Returns a TRootStructure with:
types— array ofTTopLevelStructure(interfaces and type aliases)builtins— array ofTBuiltinStructuremode—"graph"for a parsed schema,"builtins"for a builtins graph
Extracts a serializable API tree from a parsed graph. Walks the graph from the given entry interface and returns a clean tree of namespaces and endpoints, plus a flat list of all type declarations. The result is fully JSON-serializable (no symbols, no circular references) — suitable for documentation generation, introspection, or any tooling that needs to understand the API structure.
import { extractApi } from "@dldc/ts-api/server";
import { parseFromFile } from "@dldc/ts-api/server/filesystem";
import type { ApiNode } from "@dldc/ts-api/server";
const graph = parseFromFile<{ Graph: Graph }>(resolve("./api/schema.ts"));
const api = extractApi(graph, "Graph");
// Walk all endpoints
function visit(node: ApiNode) {
if (node.kind === "endpoint") {
console.log(node.path.join("."), node.arguments, node.returns);
} else {
node.children.forEach(visit);
}
}
visit(api.root);
// Graph.version [] { kind: "primitive", type: "string" }
// Graph.users.list [] { kind: "array", items: { kind: "ref", name: "User", ... } }
// Graph.users.byId [{ name: "id", ... }] { kind: "ref", name: "User", ... }Returns an ApiTree with:
entry— the name of the root interface (theentryargument)root— anApiNamespacecontaining nestedApiNamespaceandApiEndpointnodestypes— array ofApiTypeDeclaration(all interfaces and type aliases from the schema)
Each ApiEndpoint has:
path— e.g.["Graph", "users", "byId"]arguments— array of{ name, type, optional }returns— anApiType
Each ApiTypeDeclaration has:
name,kind("interface"or"alias"),parameters(generic type params)properties(for interfaces) — array of{ name, type, optional }type(for aliases) — anApiType
ApiType is a discriminated union covering all supported type constructs:
primitive, literal, array, nullable, union, object, ref,
builtin, and function. Refs are preserved as { kind: "ref", name, params }
so that generic types like Paginated<TodoItem> stay cross-referenceable.
Builtins (e.g. Date) are resolved to { kind: "builtin", name }.
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.