# Runtime Response Validation

ESI.ts validates API responses at runtime using [Zod](https://zod.dev/) 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:

1. Response comes back from ESI
2. JSON is parsed
3. Schema validates the parsed data (if `validateResponse` is enabled)
4. 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

```typescript
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:

```typescript runnable
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:

```typescript
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](TESTING.md#fault-injection)).

### 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.

<!-- doc-example: no-check contributor example: code inside src/, not a consumer import -->

```typescript
// 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](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 <!-- metric:schemaModules -->36<!-- /metric --> domain modules have schemas    |

Import individual schemas from `@lgriffin/esi.ts`:

```typescript
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:

```typescript
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(),
});
```
