Article 03 APIs / Zero-trust, schema-first

From as MyType to Real API Contracts.

Published
Reading time
12 minutes
Topics

How to combine TypeScript, runtime schemas, authorization, and database guarantees without pretending one of them can do every job.

Laptop showing code on a bright, minimal desk next to a large monitor
On this page

Your editor is happy. The build is green. Then a customer opens a page and gets amount.toFixed is not a function.

The backend sent "49.00". Your frontend expected 49.00. Somewhere in between, this line made the disagreement disappear from the editor:

TypeScript
const invoice = (await response.json()) as Invoice;

Nothing checked the value. You told TypeScript what to believe, and TypeScript took your word for it.

Now imagine a second request. Every field has the right type, but the invoice belongs to another customer. Runtime validation will happily accept a correctly shaped identifier. You’ve solved the type mismatch and still have an authorization problem.

These failures live at different boundaries. A sound API design makes those boundaries explicit, from the browser to the service to the database.

What a type assertion actually promises

TypeScript’s as Invoice is an assertion to the compiler. It doesn’t parse, convert, or validate incoming JSON. Assertions disappear during compilation. A generic helper such as fetchJson<Invoice>() has the same limitation unless its implementation performs a real runtime check.S1

Fig. 01Same payload, two very different failures
(await response.json()) as Invoice
  1. Compile timeThe cast satisfies the editor. The build is green.
  2. Runtime{ "amount": "49.00" } flows in unchecked.
  3. Later, somewhere elseamount.toFixed is not a function
Invoice.safeParse(await response.json())
  1. Compile timeThe type is inferred from the schema itself.
  2. Runtime"49.00" fails z.number() at the boundary.
  3. ResultAn explicit, local error, or a value you can trust.
An assertion moves the error to wherever the bad value is finally used. Validation keeps it at the boundary where it arrived.

Type assertions aren’t inherently wrong. They can express knowledge the compiler can’t infer. The problem is using them as evidence about bytes that came from somewhere else.

Treat external data as unknown until you have checked it. That includes network responses, webhook bodies, queue messages, environment variables, and data returned through loosely typed database or plugin adapters.

Static types help you use known values consistently while writing code. Runtime validation establishes whether a particular value meets a contract when the program actually runs.

Schema-first means the contract can execute

Suppose your API lets an authorized support agent submit a refund request. The contract includes an invoice ID, an amount in minor currency units, and a short reason. Assume this example supports only currencies with two decimal places, so 4900 means 49.00.

In Zod, the runtime schema can also supply the TypeScript type. safeParse returns a success or failure result; z.infer derives the type of successfully parsed output.S2

TypeScriptOne schema, one type
import { z } from "zod";

export const RefundRequest = z.strictObject({
  invoiceId: z.string().uuid(),
  amountMinor: z.number().int().positive().max(1_000_000),
  reason: z.string().trim().min(10).max(500),
});

export type RefundRequest = z.infer<typeof RefundRequest>;

Here, z.strictObject rejects unknown fields. The numeric rules reject a string, fraction, non-positive number, or value above the example’s per-request ceiling. Trimming happens before the length checks on the reason.S3

Those choices are policy decisions encoded into the contract. If a browser form provides a string, deliberately convert its input before submission. Don’t silently coerce every public API input and hope that empty strings, unexpected formats, or accidental units work out.

The ceiling above is only an illustrative request bound. Whether this invoice has enough refundable balance is a separate question that requires current business state.

Put checks at the boundaries that need them

The browser can use a shared request schema to show immediate feedback. The server must still validate the request because callers can bypass the browser. The client should also validate external responses before treating them as application data.

Fig. 02Each boundary has a different job
ClientValidate input + responses

Helpful, never sufficient. Callers can bypass the browser.

  • form feedback
  • response schema
ServiceParse + authorize + apply rules

The boundary that must hold, whatever the caller.

  • schema
  • principal
  • policy
DatabaseTransactions + constraints

Keeps invariants true when writes overlap.

  • locks
  • unique
  • foreign keys

A valid shape does not grant permission.

A permission check does not make concurrent writes safe.

Avoid importing your whole server into the frontend just to share a type. A small contract package should contain portable schemas and inferred types, with no database client, environment secrets, or server-only dependencies.

Once you’ve parsed an input into a well-defined internal representation, ordinary functions inside the same trusted process can use the inferred type. You don’t need to reparse the same value at every function call. Validate again when it crosses a real trust or serialization boundary, or when assumptions about it change.

On the server, keep the first steps explicit. This is a framework-neutral handler sketch: requirePrincipal, badRequest, and validationError are application adapters, not library functions.

TypeScriptA boundary handler
const principal = await requirePrincipal(request);

let raw: unknown;
try {
  raw = await request.json();
} catch {
  return badRequest("invalid_json");
}

const parsed = RefundRequest.safeParse(raw);
if (!parsed.success) {
  return validationError(parsed.error.issues);
}

const command = parsed.data;
// Next: authorize the operation and invoice, then transact.

Enforce body-size and content-type limits before parsing. Return a documented validation error with safe field paths and error codes; don’t echo arbitrary request bodies or internal diagnostics. Keep malformed JSON separate from a valid JSON object that fails the schema.S4

Also keep working with parsed.data. If a schema trims, transforms, or strips anything, checking the input and then continuing with the original object throws away the parser’s result.

A valid object can still be an unauthorized request

The refund request doesn’t contain tenantId, role, or approvedBy. Those values belong to trusted server context. Letting a caller supply them would invite the caller to describe the authority they’d like to have.

First check whether the principal may request refunds. Then load the invoice within the principal’s authorized tenant scope. Then apply any object-specific restrictions and the relevant business rules.

This is the difference between authentication (who is calling), authorization (what that caller may do to this object), and validation (whether the input meets the contract). A valid UUID proves none of the first two. OWASP describes missing object-level checks as Broken Object Level Authorization.S5

Fig. 03Gates a refund request passes, in order
  1. 01
    Authenticate

    Who is calling?

    Reject · 401
  2. 02
    Parse

    Is the body JSON, within size and content-type limits?

    Reject · 400 invalid_json
  3. 03
    Validate

    Does it meet the contract? Strict object, integer amount, trimmed reason.

    Reject · 422 + safe field paths
  4. 04
    Authorize the operation

    May this principal request refunds at all?

    Reject · 403
  5. 05
    Load in scope

    Does the invoice exist inside the caller’s tenant?

    Reject · 404, by denial policy
  6. 06
    Business rules

    Is there enough refundable balance right now?

    Reject · 409 conflict
  7. 07
    Transact

    Lock, re-check, reserve, record, commit.

    Commit
Status codes show one reasonable mapping. What matters is that each gate rejects before the next one runs.

If the invoice is outside the caller’s scope, follow a consistent denial policy. Some applications return a not-found response to avoid revealing that another tenant’s object exists. Whatever the policy, don’t first return the record and hope the frontend hides it.

This is where the phrase “zero trust” needs a little discipline. NIST’s model rejects implicit trust based solely on network location or ownership. An internal request still needs an appropriate identity and authorization decision. Schema validation supports that design, but it doesn’t establish a complete zero-trust architecture.S6

A perfectly validated request over a trusted network can still be an attempt to refund someone else’s invoice.

The database must hold the line during a race

Imagine an invoice with 6000 minor units left to refund. Two requests for 4000 arrive at almost the same time. Both pass the input schema. Both read the same available balance. Both decide the refund is allowed.

If the writes aren’t coordinated, you’ve accepted 8000 against 6000.

Fig. 04Two valid requests, one invoice
TimeRequest A · refund 4000Refunded so far (limit 6000)Request B · refund 4000
t1BEGIN · read refundable balance: 60000BEGIN · read refundable balance: 6000
t24000 ≤ 6000 → approve04000 ≤ 6000 → approve
t3Insert refund · COMMIT4000·
t4·8000Insert refund · COMMIT

✕ 8000 accepted against 6000. Both requests passed every check, so the invariant broke anyway.

Switch strategies to compare. Every path that changes the balance must follow the same invariant-preserving approach.

Use a transaction and a concurrency strategy that makes the invariant hold when requests overlap. For example, lock the tenant-scoped invoice row, check the remaining balance, reserve the amount, and record the refund operation before committing. Every path that changes that balance must follow the same invariant-preserving strategy.

Use a client-supplied idempotency key, scoped to the tenant and operation, to recognize retries. Store a fingerprint of the request payload with it. Reusing the key with a different payload should be a conflict, rather than a second interpretation of the same operation.

A supporting table might include these constraints:

SQLrefund_requests
CREATE TABLE refund_requests (
  id uuid PRIMARY KEY,
  tenant_id uuid NOT NULL,
  invoice_id uuid NOT NULL,
  idempotency_key text NOT NULL,
  payload_fingerprint text NOT NULL,
  amount_minor bigint NOT NULL CHECK (amount_minor > 0),
  UNIQUE (tenant_id, idempotency_key),
  FOREIGN KEY (tenant_id, invoice_id)
    REFERENCES invoices (tenant_id, id)
);

This illustrative DDL assumes invoices has a unique constraint on (tenant_id, id). The composite foreign key prevents linking the refund to an invoice under a different tenant. NOT NULL, uniqueness, checks, and foreign keys enforce specific storage invariants even if another writer bypasses the HTTP handler.S7

If an external payment service is involved, don’t pretend a database transaction makes that remote side effect atomic. Commit the local intent and an outbox event together; have a worker send the request using a stable provider idempotency key where supported, then record or reconcile the outcome.

Fig. 05The outbox pattern for a remote refund
One local transactionRefund intent + outbox event

Committed together, or not at all.

  • status: pending
WorkerSends the request

Retries with a stable provider idempotency key.

Payment providerRemote side effect

Not part of your database transaction.

  • Succeeded record the provider reference
  • Failed release the reserved amount
  • Timed out outcome unknown, so reconcile before retrying
The design needs explicit pending, succeeded, and failed states. A network timeout may mean “outcome unknown,” not “nothing happened.”

Give each layer a small, clear responsibility

The code is easier to reason about when the boundaries are visible in its structure. A practical arrangement:

Fig. 06Six folders, six jobs

Entry points

  • HTTP
  • Queue
  • MCP tool

Each establishes the same identity, contract, and policy.

  1. contracts/Request and response schemas; public typesshared with clients
  2. transport/HTTP parsing, authentication, error mappingvalidated command + principal
  3. application/Authorization and use-case orchestrationmay this principal do this?
  4. domain/Business rules and allowed state transitionsis this change valid?
  5. persistence/Scoped queries, transactions, constraintssafe under concurrency
  6. integrations/Payment clients, webhooks, queue adaptersexternal contracts checked
A modest structure, not a demand for six services. These can be six folders in one application.

The transport layer turns bytes into a validated command and an authenticated principal. The application layer decides whether that principal can execute the use case. The domain rules define the valid state change. Persistence makes the change safe under concurrent access. Integration adapters translate and validate the external systems’ contracts.

Keep transport details out of the domain. Your refund rule should not need to know whether a command came from HTTP, a queue, or an MCP tool. Each entry point must establish the same required identity, contract, and policy before invoking it.

Generated clients help when the source of truth is clear

Sharing a Zod contract works well when both sides use TypeScript and can coordinate package versions. Other systems need a language-neutral contract.

OpenAPI describes HTTP APIs, including operations and schemas. Tooling can use that description to generate clients, server interfaces, or documentation.S8 gRPC commonly starts from protocol buffer service and message definitions and generates client and server code for supported languages.S9

Choose a contract authority for each boundary. You might author OpenAPI and generate types, or author a supported schema subset and generate OpenAPI from it. The important property is a repeatable direction of generation. Hand-editing three competing descriptions creates drift.

Inspect what the generator actually produces. A typed client that deserializes JSON and asserts the expected type still needs runtime validation. A protobuf decoder can check the wire representation, but a positive refund limit, object ownership, and a permitted state transition remain application rules unless you explicitly implement additional validation and policy.

Not every schema feature translates cleanly. A custom Zod refinement that queries a database won’t become a portable OpenAPI constraint. Separate portable structural rules from business rules, and test the boundary where generated code meets actual payloads.

Validate what leaves the service, too

Returning an ORM entity directly is a quick way to expose fields you never intended to publish. Build an explicit response object, then validate it against a response schema. That catches accidental contract drift and makes the public shape reviewable.

Use a response projection that lists allowed fields. Don’t rely on a strict parser alone to remove secrets: a strict parser rejects extras; it doesn’t silently clean them. Choose response parsing behavior deliberately, including whether clients tolerate additive fields for compatibility.

On the browser side, check the HTTP status, parse the body as unknown, and validate the appropriate success or error schema. A server error page is not an invoice, even if a type assertion says otherwise. ApiError, ApiErrorBody, and ContractError below are application definitions.

TypeScriptValidating a response in the browser
// Responses tolerate additive fields (z.object strips unknown keys),
// so the server can add data without breaking older clients.
const Invoice = z.object({
  id: z.string().uuid(),
  amountMinor: z.number().int(),
  status: z.enum(["open", "paid", "refunded"]),
});

export async function getInvoice(id: string) {
  const response = await fetch(`/api/invoices/${encodeURIComponent(id)}`);
  const body: unknown = await response.json().catch(() => null);

  if (!response.ok) {
    throw new ApiError(response.status, ApiErrorBody.safeParse(body).data);
  }

  const parsed = Invoice.safeParse(body);
  if (!parsed.success) {
    throw new ContractError("invoice", parsed.error.issues);
  }
  return parsed.data; // typed by the schema, checked at runtime
}

Version changes deserve the same care. New required fields break old clients. An added enum value may break exhaustive client logic. Define compatibility rules, pin generation tooling, and run contract checks against representative old and new payloads.

Prove the failures are contained

Good boundary tests look slightly rude. Send a string where a number belongs. Add an unexpected role field. Use a valid invoice ID from another tenant. Repeat an idempotency key with a changed amount. Race two refund requests against the same balance. Simulate a provider timeout after it accepted an operation.

Check the observable result: denied access, unchanged unauthorized records, preserved balance, stable retry behavior, and a response that matches the public contract. Passing the schema’s happy path is only the beginning.

You still need ordinary API controls: protected transport, rate limits, parameterized queries, safe secret handling, and monitoring. A schema can’t replace those controls. What it can do is make the data contract explicit and executable, so the rest of the system starts from something firmer than a cast.

The next time you’re about to write as MyType after a network call, pause at that boundary. Define what the value must contain, validate it, establish who may use it, and protect the state it can change. Your editor will still be happy. This time, the running system has reasons to be happy too.

Sources and further reading

  1. S1TypeScript, Everyday Types: Type Assertions. Assertions are erased and do not validate at runtime.
  2. S2Zod, Basic usage. Parsing, safe parsing, and inferred types.
  3. S3Zod, Defining schemas. Strict objects, strings, numbers, and transforms.
  4. S4OWASP, REST Security Cheat Sheet. Input limits, access control, and error handling.
  5. S5OWASP, API1:2023 Broken Object Level Authorization. Authorization on individual objects.
  6. S6NIST, SP 800-207: Zero Trust Architecture. Explicit trust and resource protection.
  7. S7PostgreSQL, Constraints. Database integrity guarantees and their limits.
  8. S8OpenAPI Initiative, OpenAPI Specification. Language-neutral HTTP API descriptions.
  9. S9gRPC, Introduction to gRPC. Service definitions and generated code.

Technical references checked 14 Jul 2026. Examples are illustrative; code fragments are not complete applications.

Share

Continue the series

All writing