Schema validation#
Primitives accept any schema implementing StandardSchemaV1, so Zod, Valibot, ArkType, Effect Schema or a hand-written schema all work — and none of them becomes a dependency of @craft-ts. (Effect needs one conversion call; see Effect Schema.)
Use it when data crosses a boundary you don't control: a method argument, a server response, a restored value. Not when the value never leaves your own typed code — TypeScript covers that already.
Resource schemas#
Resource schemas correspond to different configurations. They are shown separately here so the documentation does not suggest that they can be combined in one declaration.
Validating a method argument#
const search = yield* query('search', {
methodSchema: SearchInputSchema,
method: (input) => ({ term: input.term }),
loader: async ({ params }) => fetchResults(params),
});
methodSchema validates the argument received by call, mutate or method; the method then receives the schema output value.
Validating reactive params#
const products = yield* query('products', {
paramsSchema: FiltersSchema,
params: () => ({ page: 1, term: searchTerm() }),
loader: async ({ params }) => fetchProducts(params),
});
paramsSchema validates the value produced by params or a reactive source.
Validating the loader result#
This is the one that matters most: the loader is where data you don't control enters the app.
const products = yield* query('products', {
loaderSchema: ProductsSchema,
params: () => ({ page: 1 }),
loader: async ({ params }) => fetchProducts(params),
});
loaderSchema covers more than the initial fetch — it validates loader results, stream values, and local writes through set, update and patch. So a value that enters the resource later, by any path, is checked the same way.
If the schema transforms (a .trim(), a coercion, a rename), the resource publishes the output type — the rest of your code sees the transformed shape, not the raw one.
`response<User>()` is a claim, not a check
With CraftHttpClient, the type parameter only asserts what the endpoint returns. Nothing verifies it at runtime:
loader: function* () {
return yield* CraftHttpClient.get(({ response }) => ({
url: '/api/products',
success: response<Product[]>(), // trusted, never verified
}));
}
Two ways to make it real. Add loaderSchema to the query, which validates whatever the loader returns:
yield* query('products', {
loaderSchema: ProductsSchema,
loader: /* the CraftHttpClient call above */,
});
Or decode at the request itself — response(...) takes any { decode(input: unknown) }, which every schema library provides:
success: response({ decode: (input) => ProductsSchema.parse(input) }),
Use loaderSchema when you want the failure to surface as a craft exception under exceptions().parse.loader and to obey the validation policy; use decode when the decoding belongs to the endpoint's own contract.
State#
State schemas are declared beside $self and validate initial values, writes, insertions and values produced by craftComputed:
const user = yield* state('user', {
$self: { id: 123, name: 'Alice' },
schema: UserSchema,
});
The input type constrains $self; the exposed reader uses the schema output type. Invalid derived values keep the last valid value when the policy rejects them.
Derived state#
A schema also validates every new value produced by a craftComputed while keeping the dependency reactive:
const price = yield* state('price', 10);
const quantity = yield* state('quantity', 2, ({ set }) => ({ set }));
const total = yield* state('total', {
$self: craftUse(craftComputed('totalSelf', function* () {
return (yield* price()) * (yield* quantity());
})),
schema: NonNegativeNumberSchema,
});
console.log(yield* total()); // 20
yield* quantity.set(3);
console.log(yield* total()); // 30
When a derived value fails validation, the configured policy decides whether the last valid value is retained or the new value is accepted.
Policy and exceptions#
The default policy rejects invalid values in development and accepts them in production. It can be replaced globally or locally:
provideCraftSchemaValidationPolicy(({ exception }) => {
monitoring.captureException(exception);
return { action: isDevMode() ? 'reject' : 'accept' };
});
query('products', {
loaderSchema: ProductsSchema,
schemaValidationPolicy: () => ({ action: 'reject' }),
// ...
});
Rejected parses produce a SCHEMA_VALIDATION_ERROR with scope: 'parse'. Resource exceptions expose the stage through exceptions().parse.method, exceptions().parse.params and exceptions().parse.loader; states expose exceptions().parse.state.
All four primitives expose hasSchema(), which is true when at least one schema is configured.
Effect Schema#
Effect Schema works, but not by handing the schema over directly. An effect/Schema is not itself a Standard Schema — you convert it once with Schema.toStandardSchemaV1, and the result goes anywhere a schema goes:
import { Schema } from 'effect';
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number,
});
const people = yield* query('people', {
loaderSchema: Schema.toStandardSchemaV1(Schema.Array(Person)),
loader: async () => fetchPeople(),
});
Nothing in @craft-ts/core knows about Effect, and @craft-ts/effect ships no adapter for this: the whole interop is the Standard Schema spec, which both sides already implement. You do not need @craft-ts/effect installed to validate with Effect Schema.
Failures behave like any other schema failure. Effect's issues become a SCHEMA_VALIDATION_ERROR on the parse channel — they are never thrown, and never surface as an Effect Cause:
craftUse(person.exceptions()).parse.state?._tag; // 'SCHEMA_VALIDATION_ERROR'
The one thing to watch: async decoding#
paramsSchema, methodSchema and the local writes (set, update, patch) are synchronous stages. They throw if a schema returns a Promise:
The query:people params schema returned a Promise where a synchronous result is required.
A plain Effect schema decodes synchronously, so it is fine at every stage. But a schema with an asynchronous transformation is only usable in loaderSchema, which is the one stage that awaits. If you need to validate against something async — a uniqueness check, a remote lookup — do it in the loader as an Effect and let its typed error flow through runEffect, rather than hiding it in a schema.
Decoded output, not encoded input#
Craft publishes the schema output. When the Effect schema decodes into a different type than it accepts, it is the decoded type the rest of your code sees:
// `Schema.Date` accepts a Date and REJECTS a string. The one that decodes is
// `Schema.DateFromString` — encoded: string, decoded: Date.
const createdAt = yield* state('createdAt', {
$self: rawFromServer, // string
schema: Schema.toStandardSchemaV1(Schema.DateFromString),
});
craftUse(createdAt()); // Date
See Also#
Persistence — validating restored values