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: craftComputed('totalSelf', function* () {
return (yield* price()) * (yield* quantity());
}),
schema: NonNegativeNumberSchema,
});
console.log(yield* total()); // 20
yield* quantity.set(3);
console.log(yield* total()); // 30When 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()); // DateSee Also
- Anatomy of a primitive
- Persistence — validating restored values
- Exceptions as values