// T3 · YAML Internals & Schema Design

Past the syntax.
Into the spec.

Parser divergence, js-yaml schema modes, the four processing layers in the YAML 1.2 spec, advanced Zod pattern design, and the typed config languages being built to replace YAML where it systematically fails.

T3 Advanced 4 sections + ref
TL;DR

The YAML 1.2 spec defines four processing layers: presentation (raw text) → representation (typed node graph with tags) → serialization (ordered, non-cyclic tree) → native (language object). Parser bugs and divergences almost always live at the representation→native boundary, where implicit tag resolution decisions differ by implementation.

js-yaml v4 exposes four schema modes: FAILSAFE (all strings), JSON (JSON types only), CORE (YAML 1.2 default), DEFAULT (deprecated, YAML 1.1). Jekyll uses Ruby Psych (YAML 1.1). Go yaml.v3 is mostly 1.2 but diverges on some edge cases. strictyaml eliminates implicit typing entirely by design.

Advanced Zod patterns — discriminated unions, superRefine for cross-field validation, .transform() for parsing at schema time, and reference() for cross-collection foreign keys — shift front matter from implicit runtime data to compile-time typed contracts.

YAML's systematic failure modes at scale (no type enforcement, merge-hostile indentation, no referential integrity, no computation) have produced a set of typed config alternatives: Dhall (typed functional), CUE (type-and-value unified), Jsonnet (data templating), HCL (Terraform's DSL), and TypeScript as config (Astro content layer's direction).

01 / The architecture

The YAML Spec's
Four Processing Layers

The YAML 1.2 specification defines processing as a pipeline of four distinct layers. Every parser implements this pipeline; the bugs, quirks, and divergences between parsers are almost all traceable to decisions made at layer boundaries — specifically between representation and native.

4 Presentation The raw Unicode character stream. What you type in the file. Encoding (UTF-8, UTF-16), byte order marks, and line endings are handled here.
↓ parse
3 Representation A directed graph of typed nodes — scalars, sequences, mappings. Each node carries an explicit or implicit tag (!!str, !!int, !!bool, !!map, etc.). This is where type resolution happens.
↓ serialize
2 Serialization An ordered, non-cyclic subset of the representation graph — a tree. Anchors and aliases collapse here: the serialization tree has no shared nodes, only values. Order of mapping keys is also established at this layer.
↓ construct
1 Native Language-specific data structures: a JavaScript object, Ruby Hash, Go map[string]interface{}, Python dict. The tagged representation nodes become typed values in the host language.

Tags: the representation layer's type system

Every YAML node has a tag. Tags are either explicit (you write them) or implicit (the parser infers them). Implicit tag resolution is the core of what makes YAML feel "magical" — and what makes it dangerous.

explicit vs implicit tags
# Explicit tags — you tell the parser the type
title:   !!str  42        ← string "42", not integer
count:   !!int  "42"      ← integer 42, parser overrides quote
active:  !!bool yes       ← bool true, regardless of schema
raw:     !!str  true      ← string "true", bypass bool resolution

# Implicit tags — the parser infers from value pattern
a: 42     ← !!int  (pattern: integer literal)
b: 3.14   ← !!float
c: true   ← !!bool  (YAML 1.2 CORE schema)
d: yes    ← !!bool  (YAML 1.1 only); !!str in YAML 1.2
e: ~      ← !!null
f: 2024-01-15  ← !!timestamp (most CORE-schema parsers)
Why this model matters in practice

The representation graph is a directed graph, not a tree. Anchor/alias pairs create shared nodes — the same node object appears at multiple positions in the graph. Most front matter use cases never exploit this, but it's why YAML can represent circular structures at the spec level (no front matter parser should ever surface this; a conforming parser's safe load mode prevents it).

What "YAML schema" means at the spec level

The YAML spec defines a schema as a combination of: the set of valid tags, the implicit tag resolution rules, and the native representation of each tag. The spec defines three schemas:

Failsafe Schema

Map, Seq, Str only

Every scalar is a string. No implicit typing at all. The minimum valid YAML schema. Basis for all others.

JSON Schema

JSON-compatible types

Adds null, bool, int, float — exactly the JSON type set. No dates, no octals, no YAML-specific extensions.

Core Schema

YAML 1.2 recommended

Superset of JSON schema. Adds YAML-style boolean alternatives (true/false only, not yes/no), octal (0o), hex, and timestamps.

The old YAML 1.1 behaviour (yes/no/on/off as booleans, 077 as octal) is not a named schema in the 1.2 spec — it's a historical accident that implementations continue to support for backwards compatibility.

02 / Implementation divergence

Parser Internals
& Schema Modes

Every major YAML parser makes different choices at the representation→native boundary. These choices determine which schema is applied by default, how edge cases are handled, and what security properties the parser provides.

js-yaml v4: four schema modes

js-yaml v4 (the parser used by Astro, Eleventy, and most Node.js tooling) exposes the schema selection explicitly. The default is DEFAULT_SCHEMA in v3 (YAML 1.1 behaviour); v4 changed the default to DEFAULT_SCHEMA = Core Schema + timestamps. You can override it:

FAILSAFE_SCHEMA
All scalars are strings. No implicit typing. The safest option. Use when you want predictable string values and will handle coercion yourself (e.g., with Zod transforms). Opt in with yaml.load(src, { schema: yaml.FAILSAFE_SCHEMA }).
JSON_SCHEMA
JSON types only. null, boolean, integer, float, string. No YAML-specific extensions. No dates. No octal. The strictest typed schema. Use when your front matter will also be read by JSON parsers or when you want the smallest possible type surface.
CORE_SCHEMA
YAML 1.2 Core. Adds YAML-style syntax for types (hex, octal with 0o, .inf, .nan). Only true/false are booleans. No Norway Problem. This is the most spec-conformant option for new projects.
DEFAULT_SCHEMA
js-yaml's historical default. CORE_SCHEMA plus timestamps (!!timestamp). Dates parse to JavaScript Date objects. This is what most Astro projects use in practice — Astro doesn't override js-yaml's defaults unless you configure it explicitly.
js-yaml v3 → v4 migration note

js-yaml v3's DEFAULT_SAFE_SCHEMA was YAML 1.1 compatible — yes/no parsed as booleans. v4 removed the 1.1 schema entirely and renamed things. If you're seeing unexpected string values for yes/no in a project that worked before, this is likely the cause: a dependency upgraded js-yaml under you.

Parser comparison matrix

Parser Lang Spec yes bool? Dates parse? Safe mode?
js-yaml v4 Node.js 1.2 Core ✗ string ✓ Date obj safeLoad (v3) / default (v4)
Ruby Psych Ruby 1.1 ✓ boolean ✓ Date obj safe_load required
Go yaml.v3 Go 1.2 (mostly) ✗ string ✓ time.Time KnownFields(true)
PyYAML Python 1.1 ✓ boolean ✓ datetime safe_load (mandatory)
ruamel.yaml Python 1.2 ✗ string ✓ datetime YAML(typ='safe')
strictyaml Python subset ✗ error ✗ error always (by design)

strictyaml: a deliberate YAML subset

strictyaml is not a conforming YAML parser — it's a library that implements a deliberately restricted subset of YAML, with mandatory schema definitions and no implicit typing. Any value that would require type inference is a parse error. Anchors and aliases are disabled. The Norway Problem cannot occur because NO with no schema annotation is rejected outright.

strictyaml — schema-required parsing
# strictyaml requires you to declare what you expect
from strictyaml import load, Map, Str, Int, Bool

schema = Map({
    "title":   Str(),
    "count":   Int(),
    "active":  Bool(),
    "country": Str(),   # "NO" stays "NO" — always
})

data = load(yaml_string, schema)
# data["active"] is Python bool True, data["country"] is str "NO"
# No ambiguity. No implicit resolution. No surprises.
Security note — PyYAML's yaml.load()

PyYAML's yaml.load() without an explicit Loader can deserialize arbitrary Python objects from YAML, including executing code. This is a well-documented critical vulnerability. Always use yaml.safe_load() or yaml.load(src, Loader=yaml.SafeLoader). Ruby's Psych.load() has the same property — only Psych.safe_load() is safe for untrusted input.

03 / Schema-driven front matter

Advanced Zod
Schema Design

Astro content collections with Zod schemas are the practical answer to YAML's implicit typing problem for web projects. The schema layer sits above the YAML parser and provides what YAML cannot: compile-time types, runtime validation, cross-field constraints, and transformation pipelines.

Discriminated unions for polymorphic content

When a collection contains multiple content types with overlapping but distinct fields, a discriminated union is more precise than optional fields with .optional() scattered throughout.

src/content/config.ts — discriminated union
import { defineCollection, z, reference } from 'astro:content';

// A "posts" collection that accepts articles and videos
const posts = defineCollection({
  schema: z.discriminatedUnion('type', [
    z.object({
      type:      z.literal('article'),
      title:     z.string(),
      pubDate:   z.date(),
      author:    reference('authors'),   ← cross-collection ref
      wordCount: z.number().int().positive(),
      draft:     z.boolean().default(false),
    }),
    z.object({
      type:       z.literal('video'),
      title:      z.string(),
      pubDate:    z.date(),
      duration:   z.number().positive(),  ← seconds, not minutes
      transcript: z.string().optional(),
      draft:      z.boolean().default(false),
    }),
  ]),
});

// In the component, TypeScript narrows the type:
// if (entry.data.type === 'article') → wordCount is number
// if (entry.data.type === 'video')   → duration is number, transcript is string|undefined

Transforms: parse at schema time

Instead of writing front matter in the format your code needs, write it in the format that's ergonomic to author, then transform it at parse time. Zod's .transform() runs once at build time — no runtime overhead in your components.

transforms — ergonomic authoring, typed output
const posts = defineCollection({
  schema: z.object({
    title: z.string(),

    // Author front matter: "Alice Chen" (string)
    // Output type:         { first: string, last: string }
    author: z.string().transform(name => {
      const [first, ...rest] = name.split(' ');
      return { first, last: rest.join(' ') };
    }),

    // Comma-separated string → string[]
    // Front matter:  tags: yaml, config, astro
    // Output:        ["yaml", "config", "astro"]
    tags: z.string()
      .transform(s => s.split(',').map(t => t.trim()))
      .pipe(z.array(z.string().min(1))),

    // Reading time: derive from wordCount, store as string
    wordCount: z.number().transform(n =>
      `${Math.ceil(n / 238)} min read`
    ),
  }),
});

Cross-field validation with superRefine

.superRefine() gives you access to the full parsed object, letting you express constraints that span multiple fields. Errors are attached to specific fields, giving consumers precise feedback.

superRefine — cross-field constraints
const posts = defineCollection({
  schema: z.object({
    title:      z.string(),
    pubDate:    z.date().optional(),
    draft:      z.boolean().default(false),
    canonicalUrl: z.string().url().optional(),
    description: z.string().max(160).optional(),
  }).superRefine((data, ctx) => {

    // Published posts must have a pubDate
    if (!data.draft && !data.pubDate) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'Published posts require pubDate',
        path: ['pubDate'],
      });
    }

    // Published posts should have a description for SEO
    if (!data.draft && !data.description) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'Published posts should have a description',
        path: ['description'],
        fatal: false,  ← warning, not error
      });
    }
  }),
});

Cross-collection references

Astro's reference() helper creates a typed foreign key between collections. At build time, Astro validates that the referenced entry exists and provides it as a typed object through getEntry().

cross-collection references
// src/content/config.ts
const authors = defineCollection({
  schema: z.object({
    name:   z.string(),
    bio:    z.string(),
    avatar: image(),
  }),
});

const posts = defineCollection({
  schema: z.object({
    title:  z.string(),
    author: reference('authors'),  ← must match an authors/ entry
  }),
});

// src/content/posts/my-post.md front matter:
---
title: My Post
author: alice-chen  ← must match src/content/authors/alice-chen.md
---

// In the component:
const author = await getEntry(post.data.author);
// author.data.name, author.data.bio, etc. — fully typed
The shift this represents

With discriminated unions, transforms, superRefine, and cross-collection references, your front matter schema becomes a contract enforced at build time. A missing pubDate on a published post is a build error, not a runtime null. A broken author reference fails the build, not the page. This is the same guarantee TypeScript provides for your code — Zod extends it to your content.

04 / Where YAML systematically fails

Beyond YAML:
Typed Config Languages

YAML's failure modes at scale are not accidental — they're architectural. Understanding what YAML cannot do by design clarifies when reaching for an alternative is the right call, and which alternative fits the context.

The five structural limitations

No type enforcement

Implicit resolution all the way down

Without a schema layer on top (Zod, strictyaml), there is no mechanism to guarantee that a field is the type you expect. A parser upgrade can change the type of an existing value without touching the YAML file.

Merge-hostile

Indentation makes conflicts ugly

A YAML merge conflict inside a nested block is frequently unparseable until manually resolved. JSON merge conflicts, while verbose, are structurally unambiguous. This makes YAML difficult to manage in high-churn config files.

No referential integrity

References are just strings

There is no built-in mechanism to validate that a string value refers to something that exists — another file, an ID in another document, a key in the same document. Astro's reference() is a bespoke solution to a gap in the format itself.

No computation

Anchors only reach within one file

Anchors cannot import from other files. You cannot compute a value from other values. Infrastructure configs that need derived values (e.g., a timeout that's 2x another value) require a templating layer — Helm, Jinja, envsubst — bolted on top.

Spec fragmentation

YAML 1.1 vs 1.2 divergence

The YAML 1.2 spec shipped in 2009. Major parsers still default to 1.1 behaviour in 2026. There is no in-band way to declare which version a document targets. A file cannot specify its own schema.

The alternatives

Dhall typed functional

A typed, functional, total programming language for config. Imports work (cross-file references with cryptographic pinning). Functions, records, unions, and types are first-class. Guaranteed to terminate — no infinite loops possible, no arbitrary code execution.

Used at large scale in some infrastructure teams. Can generate JSON and YAML as output. The type system eliminates entire classes of config errors at authoring time. Learning curve is real — it's a programming language, not a data format.

CUE unified type+value

CUE unifies types and values: a value is just a very specific type. Constraints and defaults are declared inline. Can validate, generate, and export JSON and YAML. Strong Kubernetes ecosystem adoption — cue vet against a Kubernetes schema is a common CI step.

The Google Borg configuration system influenced CUE's design. Marcel van Lohuizen (one of the original Go authors) designed it. Active project with growing tooling.

Jsonnet data templating

A pure functional language that generates JSON. Functions, imports, inheritance, and object merging are built in. Grafana's Tanka uses it for Kubernetes config management. Jsonnet libraries (jsonnet-libs) provide reusable abstractions across an infrastructure fleet.

Less strict than Dhall (no totality requirement, no type system), but far more powerful than YAML anchors. The tooling is mature; the ecosystem is Kubernetes-centric.

TypeScript as config Astro's direction

Astro's content layer (Astro 5) enables TypeScript-defined data loaders — your content can come from anywhere, typed at the source. The Zod schema is the type. No separate schema language to learn if you're already in a TypeScript project.

This is the most pragmatic option for web tooling: uses the type system you already have, integrates with your IDE, fails at build time not runtime, and doesn't require adopting a new language. The cost is coupling your content pipeline to Node.js and TypeScript.

When to stay with YAML

YAML remains the right choice when: ecosystem compatibility is non-negotiable (Kubernetes, GitHub Actions, Docker Compose — there is no alternative), the corpus is human-authored and small-to-medium (front matter for content sites, simple CI configs), or the team doesn't have bandwidth to adopt a new language. Add a schema layer (Zod, strictyaml, yamllint) rather than replacing the format.

05 / Reference

Quick
Reference

js-yaml schema modes
FAILSAFE_SCHEMA
All strings. No implicit typing. Safest.
JSON_SCHEMA
JSON types. No dates/octal/YAML extensions.
CORE_SCHEMA
YAML 1.2. Only true/false as booleans.
DEFAULT_SCHEMA
Core + timestamps. Astro's effective default.
Explicit tags (override implicit resolution)
key: !!str 42
Force string "42"
key: !!int "42"
Force integer 42
key: !!bool yes
Force boolean true (any schema)
key: !!null ""
Explicit null
Zod patterns
z.discriminatedUnion('type', [...])
Polymorphic content types
z.string().transform(fn)
Parse/reshape at schema time
.pipe(z.array(z.string()))
Chain schemas after transform
.superRefine((data, ctx) => {...})
Cross-field constraints with ctx.addIssue
reference('collection')
Validated cross-collection foreign key
image()
Astro image optimisation at schema level
Parser security checklist
PyYAML: safe_load()
Never yaml.load() on untrusted input
Ruby: Psych.safe_load()
Never Psych.load() on untrusted input
Go: KnownFields(true)
Reject unknown keys on decode
js-yaml: FAILSAFE_SCHEMA
All strings — safest for untrusted YAML
Typed config alternatives
Dhall
Typed, functional, total. Cross-file imports. No YAML gotchas.
CUE
Unified type+value. Validates and generates JSON/YAML.
Jsonnet
Data templating. Functions + imports. Kubernetes ecosystem.
TypeScript + Zod
Best for Node.js/Astro. Uses existing type system.
YAML spec layer summary
Presentation
Raw text. Encoding, line endings.
Representation
Tagged node graph. Where type resolution happens.
Serialization
Ordered tree. Anchors resolved, key order fixed.
Native
Language objects. JS object, Ruby Hash, Go map.