Guide

Schemas

Derive the native value, JSON, YAML and validation from one schema per model, with Kyo Schema or ZIO Schema.

Schemas are optional. NativeConverter is the type class every h34s API asks for, and a plain derives NativeConverter is a complete path. Add a schema when you want validation in the model, or JSON and YAML from the same definition.

With a schema, define each model once:

final case class Greeting(message: String) derives Schema, NativeSchema

That one derivation gives the native value h34s sends and reads, JSON text, YAML text (Kyo only), and validation that runs on every request body read.

Status. This page describes both bridges in 0.2.0-alpha.1. The older 0.1.0-alpha.1 had a different ZIO schema reader; see the migration guide.

Choose a bridge

KyoZIO
Modulenative-converter-kyo-schema-extnative-converter-zio-schema-ext
Packagedev.yuri4n.exts.ntcvkyoschemadev.yuri4n.exts.ntcvzioschema
Derivederives Schema, NativeSchemaderives Schema, NativeSchema (needs import zio.schema.derived)
JSONkyo-schema-json: Json.encode, Json.decodezio-schema-json: JsonCodec
YAMLkyo-schema-yaml: encodeString[Yaml], decodeString[Yaml]Not on Scala.js: zio-json-yaml is published for the JVM only
ValidationSchema[A].check(...) and friendsValidation in the schema (.validation, @validate)
MessagesYour check messagesBuilt from the rule and its bounds

The bridges depend on the native layer (h3-ext) and their schema library only. Neither installs a blanket instance: a type gets a NativeSchema only when it derives one or calls NativeSchema[A] (Kyo) or NativeSchema.derived (ZIO). A type can keep a separately derived NativeConverter; the two do not conflict.

Add kyo-schema-json and kyo-schema-yaml (Kyo 1.0.0-RC7) to your application yourself when you need JSON or YAML text. The bridge does not depend on them.

Kyo: derive, validate, use

Derive both at once for a model without checks. For a model with checks, declare the schema in the companion and derive only NativeSchema; kyo-schema has no check annotations, so this keeps one schema. This is the demo's Greetings.scala:

import dev.yuri4n.exts.ntcvkyoschema.NativeSchema
import kyo.*

/** The request model. Its one schema carries the validation; `NativeSchema` derives from it, so
  * `request.body.json[GreetingRequest]` answers 422 with these messages and the `name` path.
  */
final case class GreetingRequest(name: String) derives NativeSchema

object GreetingRequest:
    given Schema[GreetingRequest] = Schema[GreetingRequest]
      .check(_.name)(_.trim.nonEmpty, "must not be blank")
      .checkMaxLength(_.name)(80)

final case class Greeting(message: String) derives Schema, NativeSchema

The handler asks for the validated model and repeats no checks:

for
  input    <- request.body.json[GreetingRequest]
  greeting <- Env.use[Greetings](_.greet(input.name))
yield greeting

A blank name gets a 422 with {"errors":[{"message":"must not be blank","path":"name"}]}. An 81-character name gets {"errors":[{"message":"length must be <= 80","path":"name"}]}.

The same schema gives JSON and YAML text:

val json: String = Json.encode(greeting)
val back = Json.decode[Greeting](json)                         // a Result
val yaml: String = summon[Schema[Greeting]].encodeString[Yaml](greeting)

Kyo wire shapes

The native value is what JSON.parse(Json.encode(x)) would give, built without JSON text:

ScalaNative value
NoneThe key is absent. On input, absent, null and undefined all decode to None
Enum case, sealed trait caseA single-key object: {"Red":{}}, {"Circle":{"radius":1.5}}. @discriminator("type") gives {"type":"Joined",...}
Map[String, A]An object
Map[Int, A] and other keysAn array of {"key":...,"value":...}
BigDecimal, BigIntStrings. Decoding rejects (generic 422) a string longer than 1000 characters and a BigDecimal exponent beyond +/-10000, before parsing: Scala.js parses big numbers in quadratic time
Instant, DurationISO strings
Bytes (Span[Byte])A base64 string
Double NaN and infinitiesThe strings "NaN", "Infinity", "-Infinity"
LongA number; decoding accepts only safe integers (up to 2^53 - 1)

You do not write TypeScript types for these shapes: h34s generates them. See TypeScript types.

Kyo validation rules

  • Only the root schema's checks run, like Schema.validate. Put checks on the root with focus paths, such as _.customer.name. A nested type's own checks do not run.
  • Each failed check is one entry: your message, and the field path when the check is on a field (customer.name). A check on the whole value has no path.
  • A value with the wrong shape (missing field, wrong type, out-of-range integer, a non-plain object, invalid ISO or base64 text) is one generic entry: Request body does not match the expected shape.
  • An exception from a schema transform is a defect (opaque 500), never blamed on the input.
  • A fluent Schema.rename, renameAllFields or .discriminator, and adjacent, tuple or untagged unions, are converted generically; strict number checks then do not apply to those values.

ZIO: derive, validate, use

Import zio.schema.derived for derives Schema. Put normalization and validation in the schema. This is the demo's Notes.scala:

import dev.yuri4n.exts.ntcvzioschema.NativeSchema
import zio.schema.{DeriveSchema, Schema, StandardType, derived}
import zio.schema.validation.Validation

/** The JSON that the Notes routes send. One derivation: the ZIO Schema and the native converter. */
final case class NoteView(id: Int, text: String) derives Schema, NativeSchema

/** The JSON that `POST /api/notes` reads. */
final case class CreateNote(text: String) derives NativeSchema

object CreateNote:
    /** Trims the text, then requires 1-2000 characters. The native converter uses this schema. */
    given Schema[CreateNote] =
      given Schema[String] = Schema.Primitive(StandardType.StringType)
        .transform(_.trim, identity)
        .validation(Validation.minLength(1) && Validation.maxLength(2000))
      DeriveSchema.gen[CreateNote]

request.body.json[CreateNote] trims the text and checks its length. A blank text gets a 422 with {"errors":[{"message":"must have at least 1 character","path":"text"}]}.

ZIO Schema's own validation messages quote the input, so the bridge builds messages from the rule and its bounds instead, such as must have at least 3 characters or must be greater than 17. Paths look like lead.name and members[1].name. A negated rule (!) is reported with the wording of the rule it negates; this is a zio-schema limitation.

The ZIO bridge supports these shapes and fails with UnsupportedSchema when the converter is created for any other:

Schema formNative value
String, BooleanStrict primitives
IntAn integer in the 32-bit range
DoubleA finite number
Records (case classes)Plain objects; field names, aliases, defaults, transient fields and rejectExtraFields are respected
OptionAbsent or null is None
SequencesArrays
Lazy and transformed schemasThrough their underlying schema

Enums, sealed traits, maps, sets, tuples, Either and other primitives are not supported by the ZIO bridge yet.

NativeSchema(schema) also returns an explicit Codec with decode, validate and encode for code that holds a native value. Ordinary handlers do not need it.

TypeScript types

Both bridges' converters describe the TypeScript type of what they write (WireType.Described). A route that returns such a type gets that type in its generated declaration, so $fetch is typed without hand-written interfaces. See routes and typed $fetch.

Each record (and, in Kyo, each sealed trait and enum) is a named model: h34s-scala declares it once as export type Note = ..., and your app imports it with import type { Note } from '#h34s/models'. A generic instantiation such as Page[Note] is written out where it is used. See use the models in your app.

BridgeTypeScript type
KyoThe shapes in the table above: records with their wire names (@rename), optional fields as name?: T | null, collections as arrays, Map[String, A] as Record<string, T>, other maps as { key; value } arrays, sealed traits and enums as one-key objects or @discriminator objects, Double and Float as number | "NaN" | "Infinity" | "-Infinity", big numbers, instants, durations and bytes as strings
ZIORecords with their wire names (@fieldName) and without @transientField fields, Option as T | null, sequences as arrays, String, Boolean, Int, Double; a transform has its underlying type; a recursive record refers to its model by name where it repeats (unknown when the type is written out)

The ZIO type follows the bridge's encoder case by case. The Kyo type follows the schema's Structure, which does not show fluent changes such as Schema.rename, drop, add or omit policies, also in the schema of a nested type. So the Kyo bridge checks its type against its encoder: it reads and writes sample values of every shape (each union member, present and absent options, filled and empty collections) and gives unknown when an output does not match. A Kyo type with fluent changes is therefore unknown; prefer annotations (@rename, @discriminator) for types you send to TypeScript clients.

Secrets in a schema type

Secret has a NativeConverter, but no schema library knows it. Each bridge provides a Schema[Secret] that is a string on the wire: import dev.yuri4n.exts.ntcvkyoschema.secretSchema (Kyo) or dev.yuri4n.exts.ntcvzioschema.secretSchema (ZIO) where you derive a schema that holds a Secret, such as a config model. A wrong value type is the generic 422 (or a ConfigError for runtime config), without the value. JSON or YAML that you write with this schema contains the plain value, so do not encode a secret into a response or a log. See configuration.

Request errors

ProblemStatusEntries
Body is not valid JSON400Request body is not valid JSON
Wrong shape422One generic entry, no input values
Failed checks422One entry per check, with path when known
Exception in a transform, validator or converter500Opaque; diagnostics only in development or debug mode

Do not catch every Throwable and turn it into a SafeError. That would show programming errors to clients as if they were input problems.

Keep a NativeConverter-only path

You never need a schema. derives NativeConverter works with every h34s API: body reads, responses, configuration, storage and streams. Shape errors are still a generic 422. A derived NativeConverter does not validate domain rules, and version 0.9 accepts a missing or null value for a String field; check such rules in your code.

Dependencies and compatibility

ModuleSchema dependencies
native-converter-kyo-schema-extkyo-schema 1.0.0-RC7. Add kyo-schema-json and kyo-schema-yaml yourself for text formats
native-converter-zio-schema-extzio-schema 1.7.5. Add zio-schema-derivation 1.7.5 for derives Schema, and zio-schema-json for JSON text
kyo-h3-ext, zio-h3-extNone. The handler modules never bring a schema library

Both bridges use Scala 3.9.0 and NativeConverter 0.9.0. See compatibility for the tested profile.

What is tested:

  • Kyo bridge (NativeSchemaSuite, 30 tests with H3BodySuite): TypeScript types of records, sums, numbers, scalars and collections checked against the encoder output, and unknown for a fluent rename at the root and in a nested type; named models for records and sums, a generic instantiation written out, and a recursive model that stays unknown; wire equality with kyo-schema-json for records, options, enums, sealed traits, collections, numeric edges, big numbers (and their length and exponent limits), times and bytes; strict decoding; validation messages and paths; no input values in messages; no JSON text round trip; no blanket instance; JSON and YAML round trips from the same schema; secretSchema; HTTP 422 entries.
  • ZIO bridge (NativeSchemaSpec, 24 tests, with secretSchema, the TypeScript types of records, options, wire names, transient fields, transforms and recursion, named models after the TypeId, and sealed traits that stay unsupported) and ZIO request bodies (RequestBodySpec): supported shapes, transforms, accumulated entries with paths, and the 400/422 statuses.
  • The demo's Kyo routes answered 422 with must not be blank and length must be <= 80 over an h3 server; see compatibility.