How TypeBox Validation Works at HTTP Boundaries in Instatic
TLDR: Instatic validates every HTTP response against a TypeBox schema using the apiRequest wrapper, which delegates to readEnvelope and parseJsonResponse to ensure only type-safe data enters React state, throwing typed ApiError instances on validation or HTTP failures.
Instatic enforces a strict architectural rule at the HTTP layer: no data reaches React state without passing through TypeBox validation. This pattern, implemented in the CoreBunch/Instatic repository, centralizes all fetch logic in a canonical HTTP client that compiles TypeBox schemas for runtime validation while preserving static TypeScript types. By routing every network request through [apiClient.ts](https://github.com/CoreBunch/Instatic/blob/main/src/core/http/apiClient.ts), the codebase eliminates untyped boundaries that could introduce runtime errors.
The Validation Pipeline in apiClient.ts
The entry point for HTTP validation is [src/core/http/apiClient.ts](https://github.com/CoreBunch/Instatic/blob/main/src/core/http/apiClient.ts), which exports apiRequest and readEnvelope. These functions form a pipeline where raw Response objects are transformed into validated, typed data structures before reaching application logic.
The readEnvelope Gatekeeper
The readEnvelope<T> function serves as the primary validation boundary. It accepts a Response object, a TypeBox schema, and a fallback error message. If the response status is not OK, it throws an ApiError. Otherwise, it passes the body to parseJsonResponse for schema validation.
import type { TSchema, Static } from '@sinclair/typebox';
import { parseJsonResponse } from '@core/utils/jsonValidate';
export async function readEnvelope<T extends TSchema>(
res: Response,
schema: T,
fallback: string,
): Promise<Static<T>> {
if (!res.ok) {
throw new ApiError(await responseErrorMessage(res, fallback), res.status);
}
// TypeBox validation happens here
return parseJsonResponse(res, schema);
}
This pattern ensures that any data flowing from the network to the UI must conform to the schema's shape, with TypeScript's Static<T> extracting the compile-time type from the runtime schema.
Schema Compilation and Caching
Behind the scenes, parseJsonResponse handles the heavy lifting of schema compilation. Located in src/core/utils/jsonValidate.ts, this utility compiles the TypeBox schema once using typeboxCompiler and caches the validator for performance. When readEnvelope calls parseJsonResponse, the JSON body is validated against the compiled schema, and a typed result is returned or an error is thrown.
Error Envelope Validation
Instatic validates error responses with the same rigor as success payloads. The ErrorEnvelopeSchema in apiClient.ts ensures that server error bodies match the expected { error?: unknown } shape:
import { Type } from '@core/utils/typeboxHelpers';
const ErrorEnvelopeSchema = Type.Object(
{ error: Type.Optional(Type.Unknown()) },
{ additionalProperties: true },
);
When responseErrorMessage processes failed requests, it uses this schema to safely extract error details, preventing malformed error responses from crashing the application.
apiRequest: The Canonical Wrapper
The apiRequest function provides the high-level interface used throughout persistence layers. It configures request credentials, serializes JSON bodies, and delegates response handling to readEnvelope:
export async function apiRequest<T extends TSchema>(
url: string,
options: RequestInit & { schema: T; body?: object }
): Promise<Static<T>> {
const { schema, body, ...fetchOptions } = options;
const res = await fetch(url, {
credentials: 'include',
...(body && { body: JSON.stringify(body) }),
...fetchOptions,
});
return readEnvelope(res, schema, `Request to ${url} failed`);
}
By requiring a schema parameter for every request, apiRequest makes validation unavoidable, enforcing the architectural rule that every untyped boundary must be validated with TypeBox before reaching React state.
Architectural Enforcement
The repository maintains validation discipline through automated testing. [src/__tests__/architecture/boundary-validation.test.ts](https://github.com/CoreBunch/Instatic/blob/main/src/__tests__/architecture/boundary-validation.test.ts) verifies that all HTTP boundaries utilize TypeBox validation, preventing developers from importing raw fetch and bypassing the type-safe pipeline. This test ensures that apiRequest or readEnvelope are the only conduits for network data entering the application.
Practical Implementation Examples
Fetching Validated Data
To fetch user data with guaranteed type safety:
import { apiRequest } from '@core/http';
import { UserSchema } from '@core/persistence/responseSchemas';
export async function fetchCurrentUser() {
const user = await apiRequest('/admin/api/cms/users/me', {
schema: UserSchema,
});
// user is typed as Static<typeof UserSchema>
return user;
}
Handling Validation Errors
Catch ApiError instances to differentiate between HTTP failures and schema violations:
import { apiRequest, ApiError } from '@core/http';
import { PagesListSchema } from '@core/persistence/responseSchemas';
try {
const pages = await apiRequest('/admin/api/cms/pages', {
schema: PagesListSchema,
});
} catch (error) {
if (error instanceof ApiError) {
console.error(`HTTP ${error.status}: ${error.message}`);
// error.message includes the first TypeBox validation error if applicable
}
}
Direct readEnvelope Usage
For custom fetch scenarios outside apiRequest:
import { readEnvelope, ApiError } from '@core/http';
import { ExportResultSchema } from '@core/persistence/responseSchemas';
const response = await fetch('/admin/api/cms/export', { credentials: 'include' });
try {
const result = await readEnvelope(response, ExportResultSchema, 'Export failed');
// result is fully typed and validated
} catch (error) {
if (error instanceof ApiError) {
// Handles both HTTP errors and malformed JSON bodies
}
}
Summary
- Centralized validation: All HTTP traffic flows through
apiRequestin [apiClient.ts](https://github.com/CoreBunch/Instatic/blob/main/src/core/http/apiClient.ts), ensuring consistent handling. - TypeBox integration:
readEnvelopevalidates every response using TypeBox schemas compiled byparseJsonResponse. - Error safety: Both success and error responses are validated against schemas, with
ApiErrorproviding typed error information. - Architectural enforcement: Tests in
boundary-validation.test.tsprevent unvalidated boundaries from entering the codebase. - React state protection: No network data reaches React state without passing through schema validation, eliminating runtime type errors.
Frequently Asked Questions
What happens when a TypeBox schema validation fails?
When parseJsonResponse detects a schema mismatch, it generates an error message from the first TypeBox validation failure. readEnvelope catches this and throws an ApiError with the appropriate status code, preventing invalid data from propagating to React components.
Why does Instatic use TypeBox instead of Zod?
Instatic adheres to architectural constraint 272, which mandates TypeBox for all validation boundaries. TypeBox schemas are pure JSON Schema-compatible data structures that can be shared across client-server boundaries and external protocols like MCP, while avoiding the bundle size and API surface of banned alternatives.
How does the architecture test prevent unvalidated HTTP calls?
The boundary-validation.test.ts file statically analyzes imports and function calls to ensure that all network entry points use either apiRequest or readEnvelope with an explicit TypeBox schema. Any raw fetch usage that bypasses these wrappers will trigger a test failure during continuous integration.
Can readEnvelope handle streaming or non-JSON responses?
No. readEnvelope is specifically designed for JSON responses that require TypeBox validation. For streaming data or binary responses, developers should use the standard fetch API directly, though these patterns are rare in the admin interface and typically don't require the same runtime type guarantees as JSON API boundaries.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →