Design: Type System Patterns

Prerequisites

Scope

This document defines Trellis-wide patterns for schemas, validation, Result, and error modeling.

API Schema

Each service owns a local contract definition that emits the canonical trellis.contract.v1 artifact.

import { defineError, defineServiceContract } from "@qlever-llc/trellis";
import { sdk as core } from "@qlever-llc/trellis/sdk/core";

const schemas = {
  FindUser: FindUserSchema,
  User: UserSchema,
  PartnerChanged: PartnerEventSchema,
} as const;

const NotFoundError = defineError({
  type: "NotFoundError",
  fields: {},
  message: "Not found",
});

export const contract = defineServiceContract(
  {
    schemas,
    errors: {
      NotFoundError,
    },
  },
  (ref) => ({
    id: "graph@v1",
    displayName: "Graph Service",
    description: "Serve graph RPCs and publish partner change events.",
    uses: {
      required: {
        trellis: core.use({ rpc: { call: ["Trellis.Catalog"] } }),
      },
    },
    rpc: {
      "User.Find": {
        version: "v1",
        input: ref.schema("FindUser"),
        output: ref.schema("User"),
        errors: [ref.error("NotFoundError")],
        capabilities: { call: ["users.read"] },
      },
    },
    events: {
      "Partner.Changed": {
        version: "v1",
        params: ["/partner/id/origin", "/partner/id/id"],
        event: ref.schema("PartnerChanged"),
        capabilities: {
          publish: ["partners.write"],
          subscribe: ["partners.read"],
        },
      },
    },
  }),
);

Rules:

  • the local contract source defines input/output types, allowed errors, capabilities, and cross-contract dependencies
  • local contract source files should export the specialized helper result directly and should usually use top-level schemas and optional errors registries plus ref.schema(...) and ref.error(...) in the builder callback
  • the emitted manifest is the canonical cross-language artifact
  • for local TypeScript code, prefer exporting the defined contract object itself (export default contract or a named contract export) instead of manually rebuilding a parallel module-shaped object

Schema Organization

Platform-wide schemas live in the Trellis platform repo only when they are reused by Trellis-owned contracts or shared Trellis runtime libraries. Service-specific and domain-specific schemas live with the owning service or cloud package.

Typical platform layout:

libs/trellis/models/
├── <domain>/
│   ├── models/
│   ├── rpc/
│   └── events/
└── index.ts

Naming:

export const UserSchema = Type.Object({
  id: Type.String(),
  active: Type.Boolean({ default: true }),
});

export type User = Static<typeof UserSchema>;

Rules:

  • one schema per file, named after the type
  • schema constants use <Name>Schema
  • TypeScript types use <Name> without a suffix
  • event schemas describe contract bodies only; Trellis runtime metadata such as event id/time stays outside the body in prepared-event metadata and transport headers
  • simple RPC schemas may pair input and response in one file
  • operation schemas typically split input, progress, and output
  • service-specific schemas stay with the owning service

List Pagination Schemas

Trellis list RPCs use clean-break, standard page shapes unless a design doc explicitly excludes an endpoint from normal list semantics. Live offset pagination is the default for ordinary list RPCs. Cursor pagination is available for stable ID/keyset pages where callers should advance by an opaque cursor rather than a live row offset.

Offset Pagination

Request:

{
  offset?: number;
  limit: number;
}

Response:

{
  entries: T[];
  count: number;
  offset: number;
  limit: number;
  nextOffset?: number;
}

Rules:

  • limit is required; offset is optional and defaults to 0
  • responses use entries for the returned page, not domain-specific array names such as users, sessions, or reports
  • count is the current matching-row count after filters and before the page bound
  • nextOffset is present only when another bounded request can ask for the next live offset
  • this is live offset pagination, not snapshot or cursor pagination; concurrent inserts, updates, or deletes can change what appears at later offsets

Trellis provides reusable TypeBox and handler helpers for this shape:

  • PageRequestSchema
  • PageResponseSchema(entry)
  • normalizePageQuery(query, maxLimit?)
  • buildPageResponse(entries, totalCount, query, maxLimit?)

Cursor Pagination

Use cursor pagination for stable ID/keyset pages where the service can produce a next cursor from the last returned key or another stable opaque position. Cursor pages do not expose total counts or live offsets.

Request:

{
  cursor?: string;
  limit?: number;
}

Response:

{
  items: T[];
  page: {
    nextCursor?: string;
  };
}

Rules:

  • limit defaults to 100
  • the default maximum limit is 500, though endpoints may choose a narrower or wider maximum when documented
  • cursor is optional, but when present it must be a non-empty string
  • responses use items for the returned page and page.nextCursor only when another page is available
  • cursors are service-owned positions; callers should treat them as opaque

Trellis provides reusable TypeBox and handler helpers for this shape:

  • CursorQuerySchema
  • CursorPageInfoSchema
  • CursorPageSchema(item)
  • normalizeCursorQuery(query, options?)
  • buildCursorPage(items, nextCursor?)

Schema Validation

Use TypeBox and Zod for different strengths:

LibraryUse caseRationale
TypeBoxRPC schemas, event payloads, operation schemastype inference and JSON Schema compatibility
Zodservice config and ENV parsingcoercion, transforms, defaults

TypeBox example:

import { type Static, Type } from "@sinclair/typebox";

export const FindUserSchema = Type.Object({
  userId: TrellisIDSchema,
});
export type FindUserInput = Static<typeof FindUserSchema>;

Zod example:

import { z } from "zod";

const configSchema = z.object({
  ARANGO_URL: z.string().url(),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

Rules:

  • TypeBox for RPC, event, and operation wire schemas
  • do not default wire payload object schemas to closed-object additional-property rejection
  • same-lineage Trellis rollouts rely on older runtimes accepting newer payloads that add optional fields they do not know about yet
  • in TypeBox, prefer omitting additionalProperties for wire payload objects unless the boundary is intentionally closed for a documented reason
  • Zod for environment parsing and config loading
  • use one validation library per use case instead of stacking multiple libraries on the same boundary

Storage Identity

SQL-backed Trellis storage separates row identity from domain identity.

Rules:

  • SQL tables use an app-generated ULID id primary key for row identity
  • public IDs, external IDs, contract IDs, digests, session keys, and other domain identifiers remain separate semantic columns with their own constraints
  • repository and service code should query by the semantic identifier that matches the operation rather than exposing row IDs as public API identifiers
  • schema names should make the distinction clear, for example id for row identity and contract_id, trellis_id, or deployment_id for domain identity

Result Type

All Trellis public APIs and RPC handlers use Result<T, E>.

This keeps expected failures explicit as values rather than exceptions, preserves composable transforms via map, mapErr, and andThen, and supports predictable early-return and narrowing patterns.

Rules:

  • expected failures use Result, not thrown exceptions
  • language-specific implementations should preserve the same semantics even if the concrete type differs

TypeScript Typing Policy

TypeScript code in Trellis should use the strongest typing the compiler can support.

Rules:

  • do not use // @ts-nocheck
  • do not use as unknown as ...
  • prefer generic constraints, helper functions, and type guards over casts
  • use // @ts-expect-error only for a specific compiler limitation, with a short reason
  • keep runtime validation and compile-time narrowing paired together
  • if a public type must change to stay honest, prefer the stronger type even if it breaks consumers

Error Handling

Trellis-shared errors come from Trellis packages. Service-specific errors may extend the same base locally.

Built-in error roles:

  • TransportError covers Trellis transport and runtime boundary failures such as malformed replies, unavailable routes, bind/bootstrap failures, and other Trellis-owned protocol or connection problems. It should carry human-facing Trellis-native message, code, and hint values.
  • UnexpectedError remains the bucket for true internal or otherwise unexpected conditions, usually by wrapping an unplanned cause.
export class AuthError extends TrellisError<AuthErrorData> {
  override readonly name = "AuthError" as const;
  readonly reason: AuthErrorData["reason"];

  constructor(options: ErrorOptions & { reason: AuthErrorData["reason"] }) {
    super(options);
    this.reason = options.reason;
  }

  override toSerializable(): AuthErrorData {
    return { type: this.name, message: this.message, reason: this.reason };
  }
}

Each error defines or derives:

  • a unique discriminating wire type
  • a serializable data schema through static schema
  • runtime reconstruction logic
  • wire conversion

RPC rule:

  • declared RPC errors may be service-local TrellisError subclasses owned by the service contract
  • new TypeScript service-local RPC errors should normally use defineError(...)
  • generic TypeScript runtime helper typing for open serializable error payloads should use SerializableErrorData
  • for TypeScript service contracts, local error static schema values may be derived into emitted contract schemas automatically from local error runtime metadata
  • callers receive declared remote errors as reconstructed runtime instances of those classes
  • callers may also receive TransportError from the Trellis runtime when the transport or runtime boundary fails before a declared remote error can be reconstructed
  • RemoteError is a fallback for undeclared or unknown remote error payloads, not the preferred shape for declared contract errors

Wire rule:

  • the on-wire error envelope stays open
  • shared runtimes must preserve unknown error payloads for diagnostics
  • typed packages may narrow only the error types they actually know