Skip to content
LogoLogo

VIP-15: schema declarations

Status: accepted by Will, 2026-10-28.

Decision

// fragment
// VibeLang
schema User { id: string; nickname?: string; role: "admin" | "member"; home: Address }
schema Code = "root_missing" | "read_failed"           // alias form
 
// exactly this Effect TS
const User = Schema.Struct({
  id: Schema.mutableKey(Schema.String),
  nickname: Schema.mutableKey(Schema.optional(Schema.String)),
  role: Schema.mutableKey(Schema.Literals(["admin", "member"])),
  home: Schema.mutableKey(Address),
})
type User = typeof User.Type
const Code = Schema.Literals(["root_missing", "read_failed"])
type Code = typeof Code.Type

Using one is Effect's own Schema API (VIP-14), for example:

const user = try Schema.decodeUnknownEffect(User)(input)   // fails with a SchemaError
  • schema Name { fields } and schema Name = type declare a Schema constant and its type under one name.
  • Each field's schema is written from its TypeScript type: primitives, literals, unions, arrays, tuples, Record, nested object types, or another schema's name. A type outside that mapping is a compile error.
  • With exactOptionalPropertyTypes off, name?: T means Schema.optional, which accepts a missing key or undefined, matching TypeScript's default. With it on, use the optional-wrapper rule below.

The project's exactOptionalPropertyTypes setting controls mapped optional fields and tuple elements, matching TypeScript under that same configuration:

Source optional field or tuple elementSetting off (default)Setting on
name?: T / T?Schema.optional(S): missing or present undefinedSchema.optionalKey(S): missing only
name?: T | undefined / (T | undefined)?Schema.optional(S)Schema.optional(S)

The rule applies to ordinary schemas, schema errors, and nested mappings; Schema.mutableKey keeps its current position outside the optional wrapper. Native data payloads follow TypeScript's optional-property types directly. Declarations are identical to the Effect TypeScript twin under the same setting. Without exactOptionalPropertyTypes, TypeScript has no exact optional key, so neither does the sugar: a program needing Schema.optionalKey writes Effect's class or schema directly. This is not a gap to fill.

  • Refinements and transformations stay Schema constants, which a field names, as in name: NonEmptyString.

  • Ambiguity: none. schema is a contextual keyword on the same line as its name.

  • Amended 2026-10-29: the type mappings mirror TypeScript, mutability included.

    • "a" → Schema.Literal("a");
    • a union → Schema.Union([...]);
    • null / undefined → Schema.Null / Schema.Undefined;
    • T[] → Schema.mutable(Schema.Array(S)), and readonly T[] → Schema.Array(S);
    • [A, B] → Schema.Tuple([A, B]), mutable unless readonly;
    • Record<K, V> → Schema.Record(K, V);
    • a struct → Schema.Struct, with Schema.mutableKey for mutable fields.

    Sent to Will on Telegram 2026-10-29 11:28 under his standing rule 'assume I accept'; no objection by 12:00.

Corrected 2026-10-29: under "mutability included", a writable Record<K, V> is Schema.Record(K, Schema.mutableKey(V)) and Readonly<Record<K, V>> is Schema.Record(K, V), because Schema.Record(K, V).Type is readonly in effect 4.1.1; and a union made only of literals is Schema.Literals([...]), as the decision example writes it (found by the round-3 review).

Corrected 2026-10-29: a mutable tuple [A, B] is Schema.mutable(Schema.Tuple([A, B])), because Schema.Tuple([A, B]).Type is readonly in effect 4.1.1; readonly [A, B] is Schema.Tuple([A, B]) (found by the round-4 review).

  • Amended 2026-10-30: referenced schemas preserve source modifier promises by conservative admission. A required source field, a required fixed tuple position (before or after rest), or a required Record value position requires the referenced schema's inferred decoded-key optionality to be required. An effectively readonly field or readonly Record value position requires readonly decoded-key mutability. Check resolved schema flags through aliases; conflicting or unprovable needed flags are rejected by the field-mapping rule, with the repair to use an unmodified value schema and write modifiers on the source occurrence. Apply this recursively in nested mappings. Writable fields/records and optional source occurrences keep their existing mappings. This checks the flags at the mapped occurrence, not constructor spellings; it invents no flag-reset or unwrapping API. Encoded and constructor-input defaults retain Effect's behavior. Schema-error payload keys are effectively readonly even without a written readonly; this is shallow key readonlyness, not a deeply readonly decoded value. The Data chapter's mapping states the admitted cases.

  • Amended 2026-10-30: computed-key counterparts. Computed keys use the native TypeScript runtime object-literal grammar and property-key type checks of the Schema fields object, including admitted calls, parentheses and element access. Evaluate each key once in native property order, preserving getters, coercion, TDZ, thrown values and source-context throw checks. Infer the public type from the actual Schema value, not a synthetic type literal containing a key call. Reference modifiers and special-property-name rules retain their existing meaning.

  • Amended 2026-10-30: named declarations and diagnostics. A schema declaration has no type parameters (EK1502); generic Schema construction stays an ordinary generic TypeScript factory. A direct export default schema S … is EK1501: declare S and write export { S as default }. A schema error follows the error-declaration rules. These codes catalog the existing named, nongeneric declaration grammar.

  • Amended 2026-10-30: incomplete declaration diagnostics. A recognized ordinary schema declaration missing its required body or admitted alias form is EK1503. Recovery does not turn a missing body into an empty struct or reject a valid schema alias. The explicitly admitted bodyless schema error form keeps its existing rules. These codes label existing rejections; they add no admitted syntax.

  • Amended 2026-10-31: non-field members. A member of an ordinary schema body that is not a Field is EK1505. Decided by the agent under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 247).

History

One minor finding, a wrong Effect API name, fixed.

Prior art

Effect's Schema.Struct; Zod's z.object; Pydantic models.

TODO (Will)

"we also need to mark as a TODO creating a smithers generation script that properly updates the library as schema changes"

Will's words

"Yup I'm accepting this"

  • Amended 2026-11-01: mapped generic bindings. The generic built-ins Array<T>, ReadonlyArray<T>, Record<K, V> and Readonly<T> in the table mean the corresponding TypeScript-library type bindings, selected by ordinary type lookup and alias resolution, not by spelling. A local or imported type binding can shadow a library type; a value-only binding does not by itself replace the separate library type binding. When the resolved binding is not a mapped built-in, apply the existing Schema-value lookup and arity rules. A reference with no suitable Schema value is EK2109: declare a schema or name a Schema value. This does not expand arbitrary user type aliases or introduce generic Schema declarations. The array syntax T[] and readonly T[] remains independent of any written Array binding. See Shadowing mapped built-ins.

  • Amended 2026-11-02: written schema types (foldkit-9, accepted by Will). For either ordinary schema X form, one written, nongeneric type X = typeof X.Type in the same scope replaces the synthesized alias, in either source order. Its type query resolves to that schema value. Preserve each declaration’s own JSDoc, order and export status as on the native constant/type pair; native comment/visibility emit options still apply. Other type-name collisions remain errors.