YAML in TypeScript #

YAML (YAML Ain’t Markup Language) is a data serialization format designed for human readability — it’s the dominant choice for configuration files, CI/CD pipelines, infrastructure as code (Docker Compose, Kubernetes, Ansible, GitHub Actions), and applications needing user-editable configuration. TypeScript doesn’t have built-in YAML support like JSON, so a third-party library is needed. More importantly, because YAML parsers return unknown or any, schema validation with Zod is critical for getting type safety. This article covers how to work with YAML in TypeScript safely — from correct parsing and schema validation to the YAML syntax traps that often cause hidden bugs.

YAML Syntax — Basics and Traps #

Before working with YAML in TypeScript, it’s important to understand its basic syntax and the traps that often surprise:

# Comments use the hash sign

# --- is the document separator (optional)
---
# Mapping (like an object in JSON)
server:
  host: localhost
  port: 5432
  ssl: true

# Sequence (like an array in JSON)
databasePool:
  - nama: primary
    host: db1.example.com
  - nama: replica
    host: db2.example.com

# Multi-line string
pesanSelamatDatang: |
  Selamat datang di MuslimApps!
  Semoga bermanfaat untuk umat.  

# Folded multi-line string (newlines become spaces)
deskripsi: >
  Aplikasi super untuk
  umat Muslim Indonesia.  

# Anchor (&) and alias (*) to avoid duplication
defaultLogging: &defaultLogging
  level: info
  format: json

service1:
  logging:
    <<: *defaultLogging  # Merge from the anchor
    level: debug          # Override one value

Trap: The Norway Problem and Ambiguous Values #

YAML 1.1 (used by many older libraries) has surprising automatic conversions:

# TRAP: Values that get misinterpreted

# Norway problem — the country code "NO" is interpreted as the boolean false!
kodeNegara: NO    # false (not the string "NO"!)
kodeNegara2: "NO" # The correct "NO" — always quote ambiguous strings

# Surprising boolean values in YAML 1.1
aktif1: yes   # true
aktif2: on    # true
aktif3: y     # true
aktif4: no    # false
aktif5: off   # false
aktif6: n     # false

# Surprising octal numbers
izinFile: 0755  # 493 (decimal!) not the string "0755"
izinFile2: "0755" # "0755" the correct string

# Auto-parsed dates
tanggal: 2025-05-07  # A Date object, not a string!
tanggal2: "2025-05-07"  # The correct string if you need a string
Always use quotes (" or ') for ambiguous string values in YAML — two-letter country codes, boolean-like values (yes/no/on/off/true/false), numbers with leading zeros, and date-like strings. This prevents the parser from silently interpreting them as other types.

Setup: YAML Libraries for TypeScript #

There are two main YAML libraries for Node.js/TypeScript:

# js-yaml — the most popular library, YAML 1.2
npm install js-yaml
npm install --save-dev @types/js-yaml

# yaml — a modern, more complete, TypeScript-first library
npm install yaml

Main differences:

Aspectjs-yamlyaml
TypeScript supportVia @types/js-yamlNative TypeScript
YAML version1.21.2 (stricter)
APIload, dumpparse, stringify
Multi-documentloadAllparseAllDocuments
Custom typesType classcustomTags
Bundle sizeSmallerLarger

Parsing YAML with js-yaml #

import * as yaml from "js-yaml";
import { z } from "zod";

// Parse a YAML string into an object
const yamlString = `
server:
  host: localhost
  port: 3000
  ssl: false
database:
  url: postgresql://localhost:5432/muslimapps
  maxConnections: 10
fitur:
  jadwalSholat: true
  kiblat: true
  zakat: false
`;

// js-yaml returns unknown — DON'T cast directly to a type
const rawData = yaml.load(yamlString);
// Type: string | number | object | null | undefined

// Validate with Zod for type safety
const SchemaKonfigurasi = z.object({
  server: z.object({
    host: z.string(),
    port: z.coerce.number().min(1).max(65535),
    ssl: z.boolean().default(false),
  }),
  database: z.object({
    url: z.string().url(),
    maxConnections: z.number().int().min(1).max(100).default(10),
  }),
  fitur: z.record(z.string(), z.boolean()).optional(),
});

type Konfigurasi = z.infer<typeof SchemaKonfigurasi>;

function parseKonfigurasi(yamlInput: string): Konfigurasi {
  let raw: unknown;
  try {
    raw = yaml.load(yamlInput);
  } catch (err) {
    throw new SyntaxError(
      `YAML tidak valid: ${err instanceof Error ? err.message : String(err)}`
    );
  }

  const hasil = SchemaKonfigurasi.safeParse(raw);
  if (!hasil.success) {
    throw new TypeError(
      `Konfigurasi tidak valid:\n${JSON.stringify(hasil.error.flatten().fieldErrors, null, 2)}`
    );
  }

  return hasil.data;
}

const konfig = parseKonfigurasi(yamlString);
console.log(`Server: ${konfig.server.host}:${konfig.server.port}`);
// konfig.server.port → number (not string!)

Parsing YAML with the yaml Library #

The yaml library offers a more modern API and finer control:

import { parse, stringify, parseAllDocuments } from "yaml";
import { z } from "zod";

// Parse a YAML string
const data = parse(`
nama: Budi Santoso
usia: 25
email: [email protected]
`);
// data is typed any — still needs validation!

// Stringify: TypeScript → YAML
const objek = {
  nama: "MuslimApps",
  versi: "2.1.0",
  fitur: ["jadwal-sholat", "kiblat", "quran"],
  konfigurasi: {
    bahasa: "id",
    tema: "hijau",
  },
};

const yamlOutput = stringify(objek);
console.log(yamlOutput);
// nama: MuslimApps
// versi: 2.1.0
// fitur:
//   - jadwal-sholat
//   - kiblat
//   - quran
// konfigurasi:
//   bahasa: id
//   tema: hijau

// Stringify options
const yamlRapi = stringify(objek, {
  indent: 2,
  lineWidth: 80,
  defaultStringType: "QUOTE_DOUBLE", // Always quote strings
});

Reading YAML Configuration Files #

The complete pattern for safely reading YAML files from the file system:

import { readFile } from "fs/promises";
import * as yaml from "js-yaml";
import { z } from "zod";
import path from "path";

// Schema for the application configuration file
const SchemaAppConfig = z.object({
  app: z.object({
    nama: z.string(),
    versi: z.string().regex(/^\d+\.\d+\.\d+$/, "Format versi harus X.Y.Z"),
    env: z.enum(["development", "staging", "production"]).default("development"),
  }),
  server: z.object({
    port: z.coerce.number().default(3000),
    host: z.string().default("0.0.0.0"),
    corsOrigins: z.array(z.string().url()).default([]),
  }),
  database: z.object({
    host: z.string(),
    port: z.coerce.number().default(5432),
    nama: z.string(),
    poolMin: z.number().int().min(1).default(2),
    poolMax: z.number().int().max(50).default(10),
  }),
  redis: z.object({
    host: z.string().default("localhost"),
    port: z.coerce.number().default(6379),
    ttlDefault: z.number().int().positive().default(3600),
  }).optional(),
  logging: z.object({
    level: z.enum(["debug", "info", "warn", "error"]).default("info"),
    format: z.enum(["json", "text"]).default("json"),
  }).default({ level: "info", format: "json" }),
});

type AppConfig = z.infer<typeof SchemaAppConfig>;

async function muatKonfigurasi(namaFile = "config.yaml"): Promise<AppConfig> {
  const filePath = path.resolve(process.cwd(), "config", namaFile);

  let isiFile: string;
  try {
    isiFile = await readFile(filePath, "utf-8");
  } catch (err) {
    const error = err as NodeJS.ErrnoException;
    if (error.code === "ENOENT") {
      throw new Error(`File konfigurasi tidak ditemukan: ${filePath}`);
    }
    throw new Error(`Gagal membaca file konfigurasi: ${error.message}`);
  }

  let rawData: unknown;
  try {
    rawData = yaml.load(isiFile);
  } catch (err) {
    throw new SyntaxError(
      `File ${namaFile} bukan YAML yang valid: ${err instanceof Error ? err.message : String(err)}`
    );
  }

  if (rawData === null || rawData === undefined) {
    throw new Error(`File konfigurasi kosong: ${filePath}`);
  }

  const hasil = SchemaAppConfig.safeParse(rawData);
  if (!hasil.success) {
    const errors = hasil.error.flatten().fieldErrors;
    const pesanError = Object.entries(errors)
      .map(([field, msgs]) => `  ${field}: ${(msgs ?? []).join(", ")}`)
      .join("\n");
    throw new TypeError(`Konfigurasi tidak valid:\n${pesanError}`);
  }

  return hasil.data;
}

// Example config/config.yaml that gets read:
// app:
//   nama: MuslimApps
//   versi: "2.1.0"
//   env: production
// server:
//   port: 3000
//   corsOrigins:
//     - "https://muslimapps.id"
// database:
//   host: db.internal
//   nama: muslimapps_prod

Multi-Document YAML #

YAML supports multiple documents in one file, separated by ---:

import * as yaml from "js-yaml";

// A YAML file with multiple documents
const multiDokumen = `
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: muslimapps-backend
spec:
  replicas: 3
---
apiVersion: v1
kind: Service
metadata:
  name: muslimapps-backend-svc
spec:
  type: ClusterIP
  port: 3000
`;

// loadAll with a callback for each document
const dokumen: unknown[] = [];
yaml.loadAll(multiDokumen, (doc) => {
  dokumen.push(doc);
});

console.log(`Jumlah dokumen: ${dokumen.length}`); // 2

// Or use the yaml library for a more modern API
import { parseAllDocuments } from "yaml";

const allDocs = parseAllDocuments(multiDokumen);
const objects = allDocs.map((doc) => doc.toJSON());

Anchors and Aliases — Avoiding Duplication #

Anchors (&) and aliases (*) are YAML features that allow value reuse without duplication:

const yamlDenganAnchor = `
# Anchor definition
defaultConfig: &default
  timeout: 30
  retries: 3
  logLevel: info

# Alias usage — inherits everything from &default
serviceA:
  <<: *default
  port: 3001

serviceB:
  <<: *default
  port: 3002
  logLevel: debug  # Override one value

# Anchor for strings
dbPassword: &dbPass "rahasia123"
primaryDB:
  password: *dbPass
replicaDB:
  password: *dbPass
`;

const config = yaml.load(yamlDenganAnchor) as Record<string, unknown>;

// Anchors are resolved at parse time — the result is a plain object
console.log((config.serviceA as Record<string, unknown>).timeout); // 30
console.log((config.serviceB as Record<string, unknown>).logLevel); // "debug"

Serializing TypeScript to YAML #

import { stringify } from "yaml";

interface KonfigurasiDeployment {
  namaAplikasi: string;
  versi: string;
  replicas: number;
  environment: Record<string, string>;
  healthCheck: {
    path: string;
    intervalDetik: number;
  };
}

const deployment: KonfigurasiDeployment = {
  namaAplikasi: "muslimapps-api",
  versi: "2.1.0",
  replicas: 3,
  environment: {
    NODE_ENV: "production",
    LOG_LEVEL: "info",
  },
  healthCheck: {
    path: "/health",
    intervalDetik: 30,
  },
};

// Serialize to YAML
const yamlOutput = stringify(deployment, {
  indent: 2,
  lineWidth: 0, // Don't wrap long lines
});

console.log(yamlOutput);
// namaAplikasi: muslimapps-api
// versi: 2.1.0
// replicas: 3
// environment:
//   NODE_ENV: production
//   LOG_LEVEL: info
// healthCheck:
//   path: /health
//   intervalDetik: 30

// Write to a file
import { writeFile } from "fs/promises";
await writeFile("deployment.yaml", yamlOutput, "utf-8");

YAML vs JSON vs TOML Comparison #

flowchart TD
    A{Choose a Config\nFormat?} --> B{Who is\nwriting it?}

    B -- Technical developer --> C{Need\ncomments?}
    B -- End user / non-technical --> D[YAML\nEasiest to read]

    C -- Yes --> E{Structure\ncomplexity?}
    C -- No --> F[JSON\nSimple and universal]

    E -- Simple, flat --> G[TOML\nStructured and explicit]
    E -- Complex, nested --> H[YAML\nFlexible and expressive]
    E -- Also machine-read --> F

    style D fill:#51cf66,color:#fff
    style F fill:#339af0,color:#fff
    style G fill:#fcc419,color:#000
    style H fill:#51cf66,color:#fff
AspectYAMLJSONTOML
Readability✓✓ Very easy✓ Decent✓✓ Easy
Comments✓ Yes (#)✗ No✓ Yes (#)
Trailing commas✗ No
Data typesComplete + ambiguousLimited but clearExplicit
Multi-line strings✓ Native✗ Awkward✓ Limited
Tool supportVery broadUniversalGrowing
TrapsMany (Norway, etc.)FewFew
Use casesInfra, CI/CD, K8sAPIs, simple configCargo, Poetry, Hugo

Summary #

  • Always validate the yaml.load() result with Zod — like JSON.parse, YAML parsers return unknown or any; never directly type-assert without validation.
  • Use quotes for ambiguous strings — values like NO, yes, on, off, 0755, and dates (2025-05-07) can be interpreted as booleans, octals, or Dates by YAML 1.1 parsers; always quote if you mean a string.
  • The yaml library is more modern than js-yaml — it’s TypeScript-native, supports the stricter YAML 1.2 (no Norway problem), and has a more expressive API; consider migrating if starting a new project.
  • Anchors and aliases avoid duplication in large config files — &nama defines an anchor, *nama references it, <<: *nama merges it; widely used in Docker Compose and Kubernetes.
  • Multi-document YAML (separated by ---) is useful for bundling multiple Kubernetes manifests or related configs in one file; use loadAll (js-yaml) or parseAllDocuments (yaml).
  • YAML for human config, JSON for APIs — YAML is more comfortable for humans to edit (comments, no need to quote everything), JSON is better for program-to-program data exchange (deterministic, universal); choose based on who writes and reads it.
  • Fail fast on invalid config — validate and parse YAML at application startup (muatKonfigurasi() in index.ts), not mid-runtime; config errors found at startup are far easier to detect and fix.
  • Use z.coerce.number() for ports and config numbers — values typed as port: 3000 in YAML are usually numbers already, but sometimes can be strings depending on context; coerce handles both safely.

← Previous: JSON   Next: MySQL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact