Skip to content

loderunner/zod-file

Repository files navigation

npm version CI bundle size license Ko-fi donate NPM Trusted Publishing

zod-file

A type-safe file persistence library with Zod validation and schema migrations for Node.js. Supports JSON out of the box, and YAML and TOML with optional dependencies.

Installation

pnpm add zod-file
npm install zod-file
yarn add zod-file

YAML Support (Optional)

To use YAML files, install js-yaml:

pnpm add js-yaml

TOML Support (Optional)

To use TOML files, install smol-toml:

pnpm add smol-toml

Quick Start

JSON

import { z } from 'zod';
import { createZodJSON } from 'zod-file/json';

// Define your schema
const SettingsSchema = z.object({
  theme: z.enum(['light', 'dark']),
  fontSize: z.number().min(8).max(72),
});

// Create a persistence instance
const settings = createZodJSON({
  schema: SettingsSchema,
  default: { theme: 'light', fontSize: 14 },
});

// Load and save data
const data = await settings.load('./settings.json');
console.log(data.theme); // 'light' or 'dark'

await settings.save({ theme: 'dark', fontSize: 16 }, './settings.json');

YAML

import { z } from 'zod';
import { createZodYAML } from 'zod-file/yaml';

const ConfigSchema = z.object({
  database: z.object({
    host: z.string(),
    port: z.number(),
  }),
  features: z.array(z.string()),
});

const config = createZodYAML({
  schema: ConfigSchema,
  default: {
    database: { host: 'localhost', port: 5432 },
    features: [],
  },
});

const data = await config.load('./config.yaml');
await config.save(data, './config.yaml');

TOML

import { z } from 'zod';
import { createZodTOML } from 'zod-file/toml';

const ConfigSchema = z.object({
  database: z.object({
    host: z.string(),
    port: z.number(),
  }),
  features: z.array(z.string()),
});

const config = createZodTOML({
  schema: ConfigSchema,
  default: {
    database: { host: 'localhost', port: 5432 },
    features: [],
  },
});

const data = await config.load('./config.toml');
await config.save(data, './config.toml');

API

createZodJSON(options)

Creates a persistence instance for typed JSON files.

createZodYAML(options)

Creates a persistence instance for typed YAML files. Requires js-yaml to be installed.

createZodTOML(options)

Creates a persistence instance for typed TOML files. Requires smol-toml to be installed.

createZodFile(options, serializer)

Creates a persistence instance with a custom serializer. Use this to add support for other file formats beyond JSON, YAML, and TOML. See Custom Serializers for details on creating your own serializer.

Options

Property Type Required Description
schema z.ZodObject Yes The Zod schema for validating data
default T | () => T No Default value or factory when file is missing/invalid
version number No* Current schema version (required if migrations are provided)
migrations MigrationStep[] No Array of migration steps

Serializer Interface

When using createZodFile, the second argument must implement the Serializer interface. See Custom Serializers for details.

Returns

A ZodFile<T> object with:

  • load(path, options?) – Load and validate data from a file
  • save(data, path, options?) – Save data to a file

load(path, options?)

Loads data from a file, applies migrations if needed, and validates against the schema.

If a default is configured and loading fails for any reason (file missing, invalid format, validation error, etc.), returns the default value instead of throwing. Use throwOnError: true to throw errors even when a default is configured.

Options

Property Type Default Description
throwOnError boolean false Throw errors even when a default is configured

save(data, path, options?)

Encodes data using the schema and writes it to a file.

Options

Options are format-specific. For JSON, the following option is available:

Property Type Default Description
compact boolean false Save without indentation (JSON only)

YAML and TOML formats do not support save options. Custom serializers can define their own option types.

Versioned Schemas and Migrations

When your data schema evolves over time, use versioned schemas with migrations to handle backward compatibility.

import { z } from 'zod';
import { createZodJSON } from 'zod-file/json';

// Version 1 schema (historical)
const SettingsV1 = z.object({
  theme: z.string(),
});

// Version 2 schema (current)
const SettingsV2 = z.object({
  theme: z.enum(['light', 'dark']),
  accentColor: z.string(),
});

const settings = createZodJSON({
  version: 2 as const,
  schema: SettingsV2,
  migrations: [
    {
      version: 1,
      schema: SettingsV1,
      migrate: (v1) => ({
        theme: v1.theme === 'dark' ? 'dark' : 'light',
        accentColor: '#0066cc',
      }),
    },
  ],
});

Migration Rules

  1. Sequential versioning – Migrations must form a sequential chain starting from version 1
  2. Chain completeness – The last migration must be for version currentVersion - 1
  3. Version field – Files include a _version field that is managed automatically

File Format

When using versions, files are saved with a _version field:

JSON:

{
  "_version": 2,
  "theme": "dark",
  "accentColor": "#0066cc"
}

YAML:

_version: 2
theme: dark
accentColor: '#0066cc'

TOML:

_version = 2
theme = "dark"
accentColor = "#0066cc"

When not using versions, the data is saved as-is without wrapping.

Error Handling

All errors are thrown as ZodFileError with a specific code for programmatic handling:

import { ZodFileError } from 'zod-file';

try {
  const data = await settings.load('./settings.json');
} catch (error) {
  if (error instanceof ZodFileError) {
    switch (error.code) {
      case 'FileRead':
        console.error('Could not read file:', error.message);
        break;
      case 'InvalidFormat':
        console.error('File contains invalid JSON/YAML/TOML:', error.message);
        break;
      case 'InvalidVersion':
        console.error('Missing or invalid _version field:', error.message);
        break;
      case 'UnsupportedVersion':
        console.error('File version is newer than schema:', error.message);
        break;
      case 'Validation':
        console.error('Data does not match schema:', error.message);
        break;
      case 'Migration':
        console.error('Migration failed:', error.message);
        break;
      case 'MissingDependency':
        console.error('Optional dependency not installed:', error.message);
        break;
    }
  }
}

Accessing the Underlying Error

The cause property contains the original error that triggered the failure. This is useful for debugging or extracting detailed validation errors from Zod:

import { ZodFileError } from 'zod-file';
import { ZodError } from 'zod';

try {
  const data = await settings.load('./settings.json');
} catch (error) {
  if (error instanceof ZodFileError && error.code === 'Validation') {
    if (error.cause instanceof ZodError) {
      // Access Zod's detailed validation errors
      for (const issue of error.cause.issues) {
        console.error(`${issue.path.join('.')}: ${issue.message}`);
      }
    }
  }
}

Error Codes

Code Description
FileRead File could not be read from disk
FileWrite File could not be written to disk
InvalidFormat File content is not valid
InvalidVersion _version field is missing, not an integer, or ≤ 0
UnsupportedVersion File version is greater than the current schema version
Validation Data does not match the Zod schema
Migration A migration function threw an error
Encoding Schema encoding failed during save
MissingDependency An optional dependency (like js-yaml or smol-toml) is not installed

Advanced Usage

Custom Serializers

Use createZodFile with a custom serializer to support file formats beyond JSON, YAML, and TOML. A serializer implements the Serializer interface with encode, decode, and formatName properties.

Here's a simple CSV serializer for key-value pairs:

import { z } from 'zod';
import { createZodFile } from 'zod-file';

const csvSerializer = {
  formatName: 'CSV',
  decode(content: Buffer) {
    const text = content.toString('utf-8');
    const result: Record<string, string> = {};
    for (const line of text.trim().split('\n')) {
      const [key, value] = line.split(',');
      result[key] = value;
    }
    return result;
  },
  encode(data: unknown): Buffer {
    const text = Object.entries(data as Record<string, string>)
      .map(([key, value]) => `${key},${value}`)
      .join('\n');
    return Buffer.from(text, 'utf-8');
  },
};

const schema = z.object({
  host: z.string(),
  port: z.string(),
});

const config = createZodFile({ schema }, csvSerializer);

const data = await config.load('./config.csv');
await config.save({ host: 'localhost', port: '3000' }, './config.csv');

Custom Serializer Options

Serializers can define custom options for decoding and encoding. The Serializer type accepts two type parameters: load options and save options.

import { z } from 'zod';
import { createZodFile } from 'zod-file';

type XMLLoadOptions = {
  /** Strip XML comments before decoding */
  stripComments?: boolean;
};

type XMLSaveOptions = {
  /** Omit the XML declaration */
  omitDeclaration?: boolean;
  /** Indentation string (default: 2 spaces) */
  indent?: string;
};

const xmlSerializer = {
  formatName: 'XML',
  decode(content, options?: XMLLoadOptions) {
    let xml = content.toString('utf-8');
    if (options?.stripComments) {
      xml = xml.replace(/<!--[\s\S]*?-->/g, '');
    }
    // ... decode XML to object
    return decodeXML(xml);
  },
  encode(data, options?: XMLSaveOptions) {
    const indent = options?.indent ?? '  ';
    const declaration = options?.omitDeclaration
      ? ''
      : '<?xml version="1.0"?>\n';
    // ... convert object to XML
    const xml = declaration + toXML(data, indent);
    return Buffer.from(xml, 'utf-8');
  },
};

const schema = z.object({
  server: z.object({
    host: z.string(),
    port: z.number(),
  }),
});

const config = createZodFile({ schema }, xmlSerializer);

// Load with custom options
const data = await config.load('./config.xml', { stripComments: true });

// Save with custom options
await config.save(data, './config.xml', {
  omitDeclaration: true,
  indent: '\t',
});

Async Migrations

Migration functions can be async for complex transformations:

const settings = createZodJSON({
  version: 2 as const,
  schema: SettingsV2,
  migrations: [
    {
      version: 1,
      schema: SettingsV1,
      migrate: async (v1) => {
        // Perform async operations if needed
        const defaultAccent = await fetchDefaultAccentColor();
        return {
          theme: v1.theme === 'dark' ? 'dark' : 'light',
          accentColor: defaultAccent,
        };
      },
    },
  ],
});

Default Value Factory

Use a factory function for defaults that should be computed fresh each time:

const settings = createZodJSON({
  schema: SettingsSchema,
  default: () => ({
    theme: 'light',
    lastOpened: new Date().toISOString(),
  }),
});

License

Apache-2.0

About

Type-safe file persistence with Zod validation and schema migrations for Node.js. Supports JSON, YAML, and TOML.

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

Contributors