Standard Schema Integration
Historically, libraries and frameworks didn’t know how to talk to schema libraries natively. For example, If you wanted to use Zod to validate a form in React Hook Form, you couldn’t just hand the Zod schema to the form. You had to download a separate translation package (an adapter or resolver) that sat between your schema and the framework. The same was true for Effect Schema, Valibot, ArkType, and every other schema library.
This created friction. Every combination of “schema library + consumer library” needed its own adapter. If you used Effect Schema with React Hook Form, you needed one adapter. If you switched to TanStack Form, you needed a different one. The adapters added dependencies, introduced version compatibility issues, and sometimes lagged behind the libraries they connected.
Standard Schema is a specification that solves this problem. The creators of the major validation libraries agreed on a common interface that any schema library can implement. The interface is minimal: a ~standard property on every schema with a validate method that accepts unknown input and returns either { value } on success or { issues } on failure.
The issues array is the key part for error handling. Each issue has a message (string) and an optional path (array of property keys). This is the universal error shape. Regardless of whether the schema was built with Zod, Valibot, or Effect Schema, the consumer always gets the same { message, path } structure.
On the consumer side, libraries like TanStack Form, TanStack Router, and React Hook Form now accept Standard Schema directly. They call schema["~standard"].validate(input) internally and get back a standardized result.
Effect Schema implements Standard Schema v1. As a result, you no longer need adapters or resolvers when integrating Effect Schema with libraries and frameworks that are Standard Schema compliant. You hand your schema to the form library, and it just works.
Converting to Standard Schema
To convert an Effect Schema to a Standard Schema, you use Schema.toStandardSchemaV1:
import { Schema } from "effect";
const UserSchema = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Number,});
const standardSchema = Schema.toStandardSchemaV1(UserSchema);
console.log("Standard Schema: ", standardSchema);Output:
Standard Schema: { fields: { name: { ast: [String], rebuild: [Function (anonymous)], makeUnsafe: [Function (anonymous)] }, age: { ast: [Number], rebuild: [Function (anonymous)], makeUnsafe: [Function (anonymous)] } }, mapFields: [Function: mapFields], ast: Objects { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Objects', propertySignatures: [ [PropertySignature], [PropertySignature] ], indexSignatures: [] }, rebuild: [Function (anonymous)], makeUnsafe: [Function (anonymous)], '~standard': { version: 1, vendor: 'effect', validate: [Function: validate] }}The consuming library calls standardSchema["~standard"].validate(input) internally and gets back the { issues: [...] } shape. You don’t need to write any formatting code. The protocol handles it.
Using the Formatter Directly
Sometimes you’re not plugging into a form library. Maybe you’re building an API endpoint and want to return structured errors in the Standard Schema format. In that case, you can use the formatter directly with SchemaIssue.makeFormatterStandardSchemaV1:
import { Schema, SchemaIssue } from "effect";
const User = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Number.check(Schema.isGreaterThan(0)),});
const result = Schema.decodeUnknownExit(User)( { name: "", age: -5 }, { errors: "all" },);
if (result._tag === "Failure") { const fail = result.cause.reasons[0]; if (fail._tag === "Fail") { const formatted = SchemaIssue.makeFormatterStandardSchemaV1()( fail.error.issue, ); console.log(JSON.stringify(formatted, null, 2)); }}Output:
{ "issues": [ { "path": [ "name" ], "message": "Expected a value with a length of at least 1, got \"\"" }, { "path": [ "age" ], "message": "Expected a value greater than 0, got -5" } ]}Let’s trace through what’s happening step by step:
Schema.decodeUnknownExitruns the validation and returns anExitobject.- We check that it’s a
Failure(validation failed). result.cause.reasonsis the flat array of reasons. For Schema errors, there is oneFailreason containing theSchemaError.fail.erroris theSchemaError. We access.issueto get the raw issue tree.SchemaIssue.makeFormatterStandardSchemaV1()creates a formatter function. We call it with the issue (not theSchemaError).- The formatter walks the nested issue tree and flattens it into an array of
{ message, path }objects.
The result is clean, structured data that any UI can work with. No parsing strings, no regex matching. Just iterate over the issues array and display each one.