Production-ready TypeScript utilities for reading JSON files with Zod schema validation and writing JSON files atomically using temporary files and rename operations.
import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { dirname, join } from 'node:path';
import { z, ZodSchema, ZodError } from 'zod';
// Custom error types for better error handling
export class JsonFileError extends Error {
constructor(
message: string,
public readonly filePath: string,
public readonly cause?: unknown
) {
super(message);
this.name = 'JsonFileError';
}
}
export class JsonParseError extends JsonFileError {
constructor(filePath: string, cause: unknown) {
super(`Failed to parse JSON from ${filePath}`, filePath, cause);
this.name = 'JsonParseError';
}
}
export class JsonValidationError extends JsonFileError {
public readonly zodError: ZodError;
constructor(filePath: string, zodError: ZodError) {
super(`JSON validation failed for ${filePath}`, filePath, zodError);
this.name = 'JsonValidationError';
this.zodError = zodError;
}
}
export class JsonWriteError extends JsonFileError {
constructor(filePath: string, cause: unknown) {
super(`Failed to write JSON to ${filePath}`, filePath, cause);
this.name = 'JsonWriteError';
}
}
// Read options
export interface ReadJsonOptions {
encoding?: BufferEncoding;
}
// Write options
export interface WriteJsonOptions {
indent?: number | string;
encoding?: BufferEncoding;
}
/**
* Reads a JSON file and validates it against a Zod schema.
* Returns the validated and typed data.
*/
export async function readJson<T>(
filePath: string,
schema: ZodSchema<T>,
options: ReadJsonOptions = {}
): Promise<T> {
const { encoding = 'utf-8' } = options;
let content: string;
try {
content = await readFile(filePath, { encoding });
} catch (error) {
throw new JsonFileError(
`Failed to read file ${filePath}`,
filePath,
error
);
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch (error) {
throw new JsonParseError(filePath, error);
}
const result = schema.safeParse(parsed);
if (!result.success) {
throw new JsonValidationError(filePath, result.error);
}
return result.data;
}
/**
* Writes data to a JSON file atomically.
* Uses write-to-temp-then-rename pattern to prevent corruption.
*/
export async function writeJson<T>(
filePath: string,
data: T,
options: WriteJsonOptions = {}
): Promise<void> {
const { indent = 2, encoding = 'utf-8' } = options;
let jsonString: string;
try {
jsonString = JSON.stringify(data, null, indent);
} catch (error) {
throw new JsonWriteError(filePath, error);
}
// Create temp file in same directory to ensure same filesystem
const dir = dirname(filePath);
const tempPath = join(dir, `.tmp-${randomUUID()}.json`);
try {
// Write to temporary file
await writeFile(tempPath, jsonString, { encoding });
// Atomic rename
await rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw new JsonWriteError(filePath, error);
}
}
/**
* Reads JSON with a default value if file doesn't exist.
*/
export async function readJsonOrDefault<T>(
filePath: string,
schema: ZodSchema<T>,
defaultValue: T,
options: ReadJsonOptions = {}
): Promise<T> {
try {
return await readJson(filePath, schema, options);
} catch (error) {
if (
error instanceof JsonFileError &&
error.cause instanceof Error &&
'code' in error.cause &&
error.cause.code === 'ENOENT'
) {
return defaultValue;
}
throw error;
}
}
// ============ Usage Example ============
// Define your schema
const UserConfigSchema = z.object({
username: z.string().min(1),
email: z.string().email(),
preferences: z.object({
theme: z.enum(['light', 'dark', 'system']),
notifications: z.boolean(),
}),
createdAt: z.string().datetime(),
});
type UserConfig = z.infer<typeof UserConfigSchema>;
async function main() {
const configPath = './user-config.json';
// Write config
const config: UserConfig = {
username: 'developer',
email: 'dev@example.com',
preferences: {
theme: 'dark',
notifications: true,
},
createdAt: new Date().toISOString(),
};
try {
await writeJson(configPath, config);
console.log('Config written successfully');
// Read and validate config
const loaded = await readJson(configPath, UserConfigSchema);
console.log('Config loaded:', loaded);
// Read with default
const otherConfig = await readJsonOrDefault(
'./missing-config.json',
UserConfigSchema,
config
);
console.log('Config (with default):', otherConfig);
} catch (error) {
if (error instanceof JsonValidationError) {
console.error('Validation errors:', error.zodError.format());
} else if (error instanceof JsonFileError) {
console.error(`File error: ${error.message}`);
} else {
throw error;
}
}
}
main();This snippet provides a robust solution for JSON file operations in Node.js applications. The core design principle is defensive programming—every operation that can fail is wrapped in appropriate error handling with custom error types that preserve context about what went wrong and where.
The readJson function implements a three-stage process: file reading, JSON parsing, and schema validation. Each stage has its own error type, making debugging straightforward. The generic type parameter T is inferred from the Zod schema you provide, giving you full type safety without manual type annotations. Zod's safeParse method is used instead of parse to avoid throwing exceptions, allowing the function to wrap validation errors in our custom JsonValidationError class.
The writeJson function uses the atomic write pattern, which is critical for preventing data corruption. If you write directly to a file and the process crashes mid-write, you'll end up with a partially written, invalid JSON file. By writing to a temporary file first and then using rename (which is atomic on POSIX systems and most Windows configurations), you guarantee that the target file is either the old version or the complete new version—never a corrupted partial write. The temporary file is created in the same directory as the target to ensure they're on the same filesystem, as rename cannot work across filesystems.
The readJsonOrDefault helper addresses a common pattern where you want to return a default value if the config file doesn't exist yet (useful for first-run scenarios), but still throw errors for actual problems like invalid JSON or permission issues. It specifically checks for the ENOENT error code rather than catching all errors.
Custom error classes extend a base JsonFileError that always includes the file path and original cause. This follows the error cause pattern introduced in ES2022, making error chains inspectable. The JsonValidationError specifically exposes the ZodError object so callers can access detailed validation failure information, including which fields failed and why—invaluable for providing user-friendly error messages in CLI tools or APIs.