Runtime Response Validation
ESI.ts validates API responses at runtime using Zod schemas. When ESI returns data that doesn't match the expected shape — a renamed field, a changed type, a missing required property — the library catches it immediately instead of silently passing bad data to your application.
Why Runtime Validation?
TypeScript interfaces only exist at compile time. If CCP changes the ESI API (adds a required field, renames a property, changes a type), your code still compiles but silently receives wrong data. Zod schemas check the actual JSON at runtime, turning silent data corruption into an explicit error.
How It Works
Every ESI endpoint has a Zod schema that describes its response shape. After each successful HTTP response, the JSON body is validated against the schema:
- Response comes back from ESI
- JSON is parsed
- Schema validates the parsed data (if
validateResponseis enabled) - Validated data is returned to your code
Validation is on by default. Extra fields from ESI are preserved (passthrough mode), so new fields added by CCP won't break your application.
Configuration
Disable validation globally
const client = new EsiClient({
validateResponse: false, // skip all runtime validation
});When to disable
- Performance-critical hot paths where microseconds matter (validation is fast but not free)
- Consuming raw ESI data where you handle unknown shapes yourself
- Working around a temporary ESI schema change before the library is updated
In practice, the validation overhead is negligible compared to network latency. Leave it on unless you have a specific reason to disable it.
Error Handling
When validation fails, an EsiValidationError is thrown:
import {
EsiClient,
EsiValidationError,
createConsoleLogger,
isValidationError,
} from '@lgriffin/esi.ts';
const log = createConsoleLogger('info');
const client = new EsiClient();
try {
const character = await client.characters.getCharacterPublicInfo(12345);
} catch (err) {
if (isValidationError(err)) {
log.error('ESI response did not match expected schema', {
url: err.url,
details: err.validationError, // Zod error object
});
}
}A rejected body is not kept in the ETag cache: the next call to the same endpoint sends a fresh, unconditional request instead of serving the invalid body again for the endpoint's cache TTL.
EsiValidationError extends EsiError, so existing error handling that catches EsiError will also catch validation errors. The validationError property contains the full Zod error with details about which fields failed validation.
Using Schemas Directly
All Zod schemas are exported under the schemas namespace for direct use:
import { schemas, createConsoleLogger } from '@lgriffin/esi.ts';
const log = createConsoleLogger('info');
// Validate data you received from another source
const result = schemas.CharacterInfoSchema.safeParse(someData);
if (result.success) {
log.info(result.data.name); // fully typed
} else {
log.error('Invalid data', { issues: result.error.issues });
}
// Parse with assertion (throws on failure)
const character = schemas.CharacterInfoSchema.parse(someData);
// Use schemas for input validation in your own API
app.post('/character', (req, res) => {
const body = schemas.CharacterInfoSchema.parse(req.body);
// body is fully typed and validated
});Schema Design Decisions
Loose object mode
All schemas use Zod 4's z.looseObject() instead of z.object().passthrough(). This means:
- Known fields are validated against their expected types
- Unknown fields are preserved in the output, not stripped or rejected
This is intentional. CCP regularly adds new fields to ESI responses. Loose object mode ensures new fields flow through to your code without the library needing an update first.
Shape, not domain invariants
Schemas check that a field is present and of the right type, not that its value makes sense. A market order with a negative volume_remain, or a server status with negative players, satisfies its schema and is returned exactly as ESI sent it, with no error and no log. ESI is the source of truth for its data; a consumer that depends on an invariant checks it. The fault catalogue pins this (semantically-absurd-values; see TESTING.md).
Type derivation
All TypeScript types in src/types/ are derived from their Zod schemas using z.infer<>. The schemas are the single source of truth — there is no drift between the runtime validation and the compile-time types.
// In src/schemas/character.ts
export const CharacterInfoSchema = z.looseObject({
character_id: z.number().optional(),
name: z.string(),
// ...
});
// In src/types/character.ts
export type CharacterInfo = z.infer<typeof CharacterInfoSchema>;Generated types are separate
The auto-generated types in src/types/generated/esi-spec.generated.ts (161 interfaces from the ESI OpenAPI spec) are not converted to Zod schemas. They serve as a ground-truth reference for CI contract validation, not as consumer-facing types. The hand-written schemas in src/schemas/ are what the domain clients use at runtime.
The generated operations behind createEsi() (@lgriffin/esi.ts/client) have their own schemas: npm run spec:generate writes src/adapters/responseSchemas.generated.ts beside src/generated/operations.generated.ts, one Zod schema per operation's success body. For createEsi, validation of the generated operations is opt-in: set validateResponse: true and a body that does not match throws EsiValidationError with direction: 'response', its cache entry evicted as for the domain clients. Unset or false, the generated operations return the body as ESI sent it, as they did before the schemas existed. The default for createEsi will become validating in a future major release; set validateResponse: false now to keep unvalidated bodies then. The domain clients behind EsiClient are unaffected: they validate by default, as described above. The schemas follow the same allowances as the hand-written ones: objects keep fields ESI adds (z.looseObject), and a string enum accepts a member the document does not list yet. Where ESI's wire responses break its own document, scripts/spec/response-schema-overrides.json records the deviation with its evidence and widens the generated schema. In 12.x the operation's TypeScript type keeps the document's shape (UniverseBloodlinesGet's ship_type_id stays number), because widening a response type is a major change under SEMVER.md; with validateResponse: true such a field can still arrive as null at runtime, as it already does unvalidated. The type widens to match the schema in the next major release.
Available Schemas
Every domain module has a corresponding schema file in src/schemas/. The naming convention is <InterfaceName>Schema:
| Module | Schemas |
|---|---|
character | CharacterInfoSchema, CharacterPortraitSchema, CharacterAttributesSchema, ... |
alliance | AllianceInfoSchema, AllianceContactSchema, AllianceIconSchema, ... |
corporation | CorporationInfoSchema, CorporationAllianceHistorySchema, ... |
universe | SolarSystemInfoSchema, TypeInfoSchema, StationInfoSchema, ... |
market | MarketOrderSchema, MarketHistorySchema |
| ... | All 36 domain modules have schemas |
Import individual schemas from @lgriffin/esi.ts:
import { schemas } from '@lgriffin/esi.ts';
// Access any schema
schemas.CharacterInfoSchema;
schemas.MarketOrderSchema;
schemas.SolarSystemInfoSchema;Extending Schemas
You can extend the built-in schemas for your own validation needs:
import { schemas } from '@lgriffin/esi.ts';
import { z } from 'zod';
// Add custom validation
const StrictCharacterSchema = schemas.CharacterInfoSchema.extend({
name: z.string().min(3).max(37),
});
// Compose schemas
const MyResponseSchema = z.object({
character: schemas.CharacterInfoSchema,
alliance: schemas.AllianceInfoSchema.optional(),
});