What are the most confident teams using to build AI? → 2026 Benchmark Report
Featured image for There are too many JavaScript schema libraries, so support only one blog post

There are too many JavaScript schema libraries, so support only one

What Standard Schema is, and what we learned adopting it in our TypeScript SDK.

Aaron Harper· 6/10/2026 · 9 min read

TypeScript developers have no shortage of schema libraries: Zod, Valibot, ArkType, Yup, Effect Schema, Superstruct, io-ts, Runtypes, TypeBox, Joi, and more.

For library authors who accept user-defined schemas, that abundance used to collapse into three bad options:

  1. Pick one (usually Zod), frustrating everyone who picked something else.
  2. Don't support runtime validation, frustrating everyone who expected it.
  3. Roll your own validation interface and adapters, frustrating yourself.

Now there's a better way: support one schema interface that all libraries can implement. This is Standard Schema.

We switched to Standard Schema in our TypeScript SDK v4.0.

What Standard Schema is

At its core, Standard Schema is one property on a schema object:

interface StandardSchemaV1<Input, Output = Input> {
  "~standard": {
    version: 1;
    vendor: string;
    validate(
      value: unknown,
      options?: { libraryOptions?: Record<string, unknown> },
    ):
      | { value: Output; issues?: undefined }
      | { issues: readonly Issue[] }
      | Promise<
          | { value: Output; issues?: undefined }
          | { issues: readonly Issue[] }
        >;
    types?: { input: Input; output: Output };
  };
}

That small surface area is the point. Even the property name is designed to stay out of the way: the ~ prefix sorts last in autocomplete and signals "don't touch this directly."

Schema authors implement it, library authors consume it, and users never see it. Zod, Valibot, ArkType, and others ship ~standard natively. If your library accepts a StandardSchemaV1, you support all of them today and any new schema library that implements the spec tomorrow.

What our users' code looks like

From the user's perspective, nothing special is happening. They write schemas with their library of choice.

With Zod:

import { eventType } from "inngest";
import { z } from "zod";

const userCreated = eventType("user.created", {
  schema: z.object({ userId: z.string() }),
});

With Valibot:

import { eventType } from "inngest";
import { object, string } from "valibot";

const userCreated = eventType("user.created", {
  schema: object({ userId: string() }),
});

With ArkType:

import { eventType } from "inngest";
import { type } from "arktype";

const userCreated = eventType("user.created", {
  schema: type({ userId: "string" }),
});

In our library code, we just accept StandardSchemaV1. Users keep their schema library, and we avoid maintaining a growing adapter layer.

What we learned

1. Some users want static types without runtime validation

A schema library is overkill if you only want compile-time types. Standard Schema makes this case trivial: ship a passthrough implementation.

export function staticSchema<T extends Record<string, unknown>>(): StandardSchemaV1<T> {
  return {
    "~standard": {
      version: 1,
      vendor: "inngest",
      validate: (value) => ({ value: value as T }),
    },
  };
}

Users get static types with no runtime cost and no new dependencies:

const userCreated = eventType("user.created", {
  schema: staticSchema<{ userId: string }>(),
});

The helper is just a no-op validator that lets the type system do the rest. That only works because Standard Schema is small enough to implement directly.

2. Transforms can make validation non-repeatable

Event-driven systems often need double validation: producers validate before sending (so bad data never enters the system) and consumers validate when receiving (so bad data is not processed). Producers and consumers are often different processes, sometimes different codebases, so each side owns its validation against the same schema.

That works as long as validation leaves the payload unchanged. However, some schema libraries support "transforms", which break the "double validation is safe" assumption. This means that the producer's validation could return modified data that will ll fail consumer-side validation.

For example, your schema might coerce Date objects into Unix millis. After the first validation, the payload has a number instead of a Date, so the second validation fails. Here's a runnable example:

import { z } from "zod";

const schema = z
  .object({ time: z.date() })
  .transform(({ time }) => ({ time: time.getTime() }));

// Producer returns the transformed value.
async function producer() {
  const result = await schema["~standard"].validate({ time: new Date() });
  if (result.issues) {
    throw new Error("validation failed");
  }
  return result.value;
}

// Consumer validates with the same schema, but validation fails.
async function consumer(data: unknown) {
  const result = await schema["~standard"].validate(data);
  if (result.issues) {
    throw new Error("validation failed");
  }
  return result.value;
}

async function main() {
  const data = await producer();

  // Throws an error
  await consumer(data);
}

main();

Standard Schema represents transforms by allowing Input and Output to differ: a schema can accept one shape, produce another.

We disallow transforms at the type level:

type AssertNoTransform<TSchema extends StandardSchemaV1 | undefined> =
  TSchema extends undefined
    ? undefined
    : TSchema extends StandardSchemaV1<infer TInput, infer TOutput>
      ? [TInput] extends [TOutput]
        ? TSchema
        : StaticTypeError<"Transforms not supported: schema input/output types must match">
      : StaticTypeError<"Transforms not supported: schema input/output types must match">;

A transforming schema is a compile error pointing at the problem. We catch it before runtime.

Worth flagging: Input != Output doesn't guarantee a transform. For example, Zod's branded types diverge the input and output types without changing the runtime value. This can be handled, though it makes AssertNoTransform more complicated.

3. Validators can be sync or async

validate() can return a result directly or return a Promise. The schema author decides, so any code that calls validate() has to account for both.

For async APIs, this is easy: await the result and move on. sendEvent() is already async, so that's what we do.

Sync APIs are where it gets awkward. You either change the signature and break callers, or check the return type and bail when it's a Promise. Neither is great, and the choice has to be made per API.

4. Validators can still throw

validate() returns {value} | {issues} | Promise<...>, so the signature makes validation failures look like returned values, not thrown errors. In practice, validators can still throw. The spec doesn't forbid it, and validators are still userland code.

If you trust the signature too much, a thrown validator can escape your validation path entirely. We wrap every validator call in try/catch and treat thrown errors the same as failed validation results.

5. Multiple schemas need routing

Standard Schema validates one schema against one value. If an entry point accepts multiple shapes, you still have to decide which schema should handle a given input.

Two common patterns:

  • Discriminator field. Pick a schema from a known property on the input, then validate against only that one. This gives you precise errors and does the minimum work.
  • Union. Try every plausible schema and accept the input if any pass. This works when no clean discriminator exists, but failures are harder to explain because every schema may reject the input for a different reason.

For us, events carry their name as the discriminator. A function can declare several triggers, sometimes with overlapping event names and different schemas. We narrow to candidate schemas by event name, then validate against each candidate in parallel when more than one applies:

async function throwIfAllRejected(promises: Promise<void>[]) {
  let lastError: unknown;

  const settled = await Promise.allSettled(promises);
  for (const result of settled) {
    if (result.status === "fulfilled") return;
    lastError = result.reason;
  }

  throw lastError;
}

This routing logic isn't Standard Schema-specific, but the uniform interface makes it cheap. A user can mix a Zod union, a Valibot variant, and an ArkType schema in the same set of triggers. They all validate through the same ~standard shape, so the routing code doesn't care where they came from.

6. The types are more work than the runtime

This isn't unique to Standard Schema, but it's easy to underestimate: the runtime work is small, and the type work is not.

At runtime, accepting a schema is mostly a validate() call, an issues check, and a try/catch. The hard part is making the validated data show up with the right type everywhere in your public API. One entry point means one inference path. Many entry points means many inference paths, and they all have to agree on the inferred type for the same schema.

For us, that meant threading the schema through function triggers, function arguments, step.waitForEvent, step.invoke, and a few other APIs. The runtime work was a few function calls; the type work was several hundred lines of conditional inference.

Standard Schema doesn't really change this cost. You'd write the same inference if you accepted z.ZodSchema directly. The point is that the integration cost lives in the types, regardless of which schema interface you choose.

Why this matters beyond our SDK

Schema libraries compete on different axes: ecosystem size, bundle size, compile-time speed, framework integration, and more. Zod, Valibot, ArkType, and Effect Schema all make different tradeoffs. There is no obvious winner.

When a library author picks one, users of the others either lose support or install a second schema library just for your API. Standard Schema makes that choice less important: library authors stop maintaining adapters, and users keep the schema library they already chose.

What Standard Schema doesn't solve

Standard Schema is deliberately narrow: it standardizes validation, not the rest of a schema library's surface area. Error formatting, defaults, metadata, and schema introspection are still library-specific. That was fine for us because we only needed runtime validation for typed event payloads.

If you need JSON Schema representations of your types (for OpenAPI, docs, or other schema-driven tooling), the related Standard JSON Schema spec exposes a common interface for converting schemas to JSON Schema. That's a separate interface from the validation interface we adopted here.

Takeaways

If you're shipping a library that accepts user-defined schemas, consider Standard Schema before committing to one schema library or maintaining adapters for several. The amount of code involved is small. The amount of user friction it removes is not.

There are too many JavaScript schema libraries. The fix isn't fewer libraries. It's an interface they can all agree on.