15 Actually Useful npm Packages (2026)
In this tutorial, you'll learn about 15 actually useful npm packages (2026). We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Every npm list starts with Express, Lodash, and Moment. This one doesn't. Those are everywhere — you already know them. This list focuses on modern, well-maintained packages (heavily from the unjs ecosystem) that solve real problems you hit daily: validation, bundling, config management, logging, and Serialization. Each entry earned its spot by being genuinely useful rather than just popular.
In this guide, you will learn about 15 npm packages that solve specific development problems with minimal configuration overhead. These are the packages that experienced Node.js developers reach for when they need runtime validation, fast TypeScript bundling, configuration file management, or structured logging. Each package is production-tested and actively maintained as of 2026.
Validation & Parsing
zod — Schema declaration and validation with TypeScript inference. Define a schema once and get both runtime validation and static types. Unlike Joi or Yup, Zod infers TypeScript types automatically so you never maintain duplicate type definitions.
Zod is the most popular schema validation library in the TypeScript ecosystem as of 2026. You define a schema using Zod's fluent API, and TypeScript infers the static type automatically. This eliminates the duplication of writing both a TypeScript interface and a runtime validation function that checks the same structure. The z.infer<typeof mySchema> utility type extracts the TypeScript type from any Zod schema.
Zod supports all JavaScript data types including primitives (z.string(), z.number(), z.boolean()), complex objects (z.object({ name: z.string() })), arrays (z.array(z.string())), tuples, enums, unions, intersections, and discriminated unions for tagged union types. The .parse() method throws a ZodError with detailed messages on validation failure, while .safeParse() returns a result object with success: true and data or success: false and error. Custom error messages are supported via the .refine() method.
For real-world use, Zod excels at API request validation, environment variable validation, and form data parsing. The z.object() schema can .strip() unknown properties, .passthrough() to allow them, or .strict() to throw on unexpected keys. The .transform() method lets you coerce values during parsing — converting string dates to Date objects, trimming whitespace, or normalizing formats.
import { z } from 'zod';
// Define a schema once — TypeScript infers the type automatically
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['admin', 'user', 'viewer']),
tags: z.array(z.string()).default([]),
});
// Inferred type: { id: string; email: string; age?: number; role: 'admin' | 'user' | 'viewer'; tags: string[] }
type User = z.infer<typeof UserSchema>;
// Runtime validation
const result = UserSchema.safeParse({
id: '123e4567-e89b-12d3-a456-426614174000',
email: 'user@example.com',
role: 'admin',
});
if (!result.success) {
console.error(result.error.format());
}
destr — A tiny (200 bytes) utility for safely parsing strings into their native JavaScript types. It handles JSON, numbers, booleans, null, undefined, and NaN without throwing. Perfect for parsing environment variables and query parameters where you don't know the format ahead of time.
Destr solves a common problem: you have a string value from an environment variable, URL parameter, or configuration file, and you want to coerce it to the correct JavaScript type without writing conditional logic. destr('true') returns true (boolean), destr('42') returns 42 (number), destr('null') returns null, destr('undefined') returns undefined, and destr('{"a":1}') returns {a: 1} (parsed JSON).
The coercion rules are designed for safety. Strings that look like numbers but contain leading zeros are treated as strings to avoid octal interpretation. Strings that start with 0x or 0o are treated as strings to avoid accidental hex/octal parsing. The JSON.parse path is only attempted for strings that look like JSON (start with { or [). If the string does not match any special pattern, it returns the original string unchanged.
Destr is ideal for parsing process.env values where all values are strings regardless of their intended type. Instead of writing const port = parseInt(process.env.PORT || '3000'), you write const port = destr(process.env.PORT) ?? 3000 and get the correct number type. It also handles boolean env vars naturally: DESTR_DEBUG=true becomes true instead of the string 'true'.
import { destr } from 'destr';
// Environment variable parsing
const config = {
port: destr(process.env.PORT) ?? 3000,
debug: destr(process.env.DEBUG) ?? false,
database: destr(process.env.DATABASE_URL),
};
// Query parameter parsing
const params = new URLSearchParams(window.location.search);
const page = destr(params.get('page')) ?? 1;
const limit = destr(params.get('limit')) ?? 20;
// Result types are correctly inferred
console.log(typeof config.port); // 'number'
console.log(typeof config.debug); // 'boolean'
ohash — Ultra-fast hash computation for objects, buffers, and strings. Uses a non-cryptographic algorithm optimized for consistency checks and cache invalidation. Significantly faster than JSON.stringify + hash for object comparison.
Ohash computes a deterministic hash of any JavaScript value using a non-cryptographic algorithm optimized for speed. The hash is consistent across process restarts and machine architectures — the same input always produces the same output. This makes it suitable for cache keys, ETag generation, file fingerprinting, and change detection in development tools.
The primary use case is object hashing for cache invalidation. When you have an object representing function arguments or configuration, ohash produces a stable hash that changes only when the object content changes. This is more efficient than serializing the object to JSON and hashing the string, because ohash handles circular references, special types (Date, RegExp, Map, Set), and non-serializable values that JSON.stringify cannot process.
Ohash also supports incremental hashing for streaming data. The ohash function accepts strings and Buffer objects directly for file content hashing. The hash length is configurable via the serialize function which controls which properties are included. This is useful when you want to hash only specific subset of an object's properties for targeted cache invalidation.
import { ohash } from 'ohash';
// Hashing objects for cache keys
const cacheKey = ohash({ query: 'search', page: 1, filters: { status: 'active' } });
// Returns a consistent 8-character hash string
// Hashing strings
const fileHash = ohash('file content here');
// Cache invalidation example
const cache = new Map();
function getCachedResult(key, computeFn) {
const hash = ohash(key);
if (cache.has(hash)) {
return cache.get(hash);
}
const result = computeFn();
cache.set(hash, result);
return result;
}
Bundling & Building
tsup — Bundle TypeScript libraries with zero configuration. Uses esbuild under the hood for 10-100x faster builds than tsc. Handles CJS, ESM, and dual-package output from a single config. The go-to tool for publishing npm packages in 2026.
Tsup is a TypeScript bundler built on top of esbuild that requires almost no configuration. Run tsup src/index.ts and it outputs a bundled CommonJS file to dist/index.js and an ES module to dist/index.mjs with TypeScript declarations generated automatically. The default configuration covers the vast majority of library publishing needs without requiring a tsup.config.ts file.
The key advantage over tsc is build speed. Tsup leverages esbuild's Go-based compiler which is 10-100x faster than the TypeScript compiler for bundling. Where tsc might take 30 seconds to compile a medium-sized library, tsup completes in under a second. This makes the edit-compile-test cycle fast enough for interactive development without a separate watch mode.
Tsup supports Code Splitting, tree shaking, and platform-specific bundles. The --format flag controls output formats: cjs, esm, and iife. The --dts flag generates TypeScript declaration files. The --splitting flag enables Code Splitting for large libraries. The --target flag sets the ECMAScript target. For library authors, the --clean flag removes the dist directory before each build, preventing stale files from being published.
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
target: 'node18',
outDir: 'dist',
});
// Build with: npx tsup
// Output:
// dist/index.js (CommonJS)
// dist/index.mjs (ES Module)
// dist/index.d.ts (TypeScript declarations)
// dist/index.d.mts (ESM declarations)
Config & Environment
c12 — Universal configuration loader for Node.js. Reads config from multiple sources (package.json, rc files, env vars, CLI flags) with a unified API. Supports TypeScript config files natively and auto-reloads on change. The configuration layer behind modern unjs tools.
C12 is a configuration loading library that resolves configuration from multiple sources with a well-defined priority order. The default resolution chain checks: CLI flags (highest priority), environment variables, local config files (.config/, .config.ts, .config.json), user-level config files (~/.config/), and finally package.json fields. Each source overrides the previous one, giving users fine-grained control while maintaining sensible defaults.
The TypeScript config file support is a standout feature. C12 uses jiti (a just-in-time TypeScript interpreter) to load .ts config files at runtime without precompilation. This means you can write typed configuration with IDE autocompletion and documentation, while the end user just runs the tool without any build step. Config files support defineConfig for type-safe configuration, default exports, and async factory functions.
C12 also supports config watching for development tools. The watch option watches config files for changes and emits an event when they are updated, enabling hot-reloading of configuration without restarting the process. The overrides option lets you inject config values programmatically for testing. The resolved config is cached for performance, with automatic cache invalidation when source files change.
import { defineConfig, loadConfig } from 'c12';
// Config file (my-tool.config.ts)
export default defineConfig({
port: 3000,
database: {
host: 'localhost',
port: 5432,
},
debug: false,
});
// Loading the config
const { config } = await loadConfig({
name: 'my-tool',
cwd: process.cwd(),
overrides: { debug: true }, // Override for this run
});
console.log(config.port); // 3000 (or overridden value)
rc9 — Read/write .conf files (like .gitconfig or .npmrc) with automatic section parsing. Unlike ini, it handles nested keys, arrays, and preserves formatting on write. Essential for tools that manage user configuration files.
Rc9 is a configuration file parser and serializer designed for the .conf format used by many CLI tools (.npmrc, .gitconfig, .editorconfig). Unlike the ini package which has edge cases with comments and whitespace preservation, rc9 preserves formatting when writing — comments stay in place, indentation is maintained, and key ordering is respected. This is critical for tools that modify user configuration files because users notice when their carefully formatted config gets scrambled.
The API supports reading, writing, and updating individual keys within section-based config files. The parse function reads a .conf string into a nested object. The stringify function converts the object back to a .conf string with formatting preservation. The update function modifies a specific key in an existing config file while preserving everything else.
Rc9 handles edge cases that simpler parsers miss. Keys can contain dots to represent nested sections. Values can be quoted strings, numbers, booleans, or arrays of values. Comments (# and ;) are preserved on write. Environment variable interpolation (${VAR}) is supported for values. Section headers ([section]) group related keys.
import { parse, stringify, update } from 'rc9';
// Parse a .conf file
const config = parse(`
# Database configuration
[database]
host = localhost
port = 5432
[logging]
level = info
`);
console.log(config.database.host); // 'localhost'
// Stringify back to .conf format
const output = stringify(config);
// Update a specific key
const updated = update(config, 'database.port', '5433');
// Result: database.port changes to 5433, rest stays the same
dotenv — Loads .env files into process.env with zero configuration. The standard for managing environment variables across environments. Use with dotenv-expand for variable interpolation.
Dotenv loads environment variables from a .env file into process.env. Call dotenv.config() at the top of your application entry point, and it reads the .env file from the current working directory, parses each line as KEY=VALUE, and sets the corresponding process.env property. Existing environment variables take precedence over .env file values by default.
The package supports .env, .env.local, .env.development, and .env.production files with a priority chain. The dotenv-expand companion package adds variable interpolation within the .env file itself — you can reference previously defined variables using $VAR or ${VAR} syntax. This is useful for constructing connection strings from component parts.
For security, never commit .env files to version control. Add .env to .gitignore and instead commit a .env.example file with placeholder values and documentation for each variable. The .env.local file (git-ignored by convention) stores local overrides, while .env.production is set up on the production server directly.
# .env file
PORT=3000
DATABASE_URL=postgres://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
NODE_ENV=development
LOG_LEVEL=debug
# With dotenv-expand
APP_NAME=myapp
APP_VERSION=1.0.0
CACHE_KEY=${APP_NAME}:${APP_VERSION}:cache
scule — Case conversion utility that handles every format: camelCase, snake_case, kebab-case, PascalCase, and CONSTANT_CASE. Unlike lodash.camelCase, it correctly handles acronyms and numbers.
Scule is a case conversion library that correctly handles edge cases that simpler libraries get wrong. The camelCase function converts any string to camelCase while preserving acronyms: camelCase('parseJSON') returns 'parseJSON' (not 'parseJson'). The pascalCase, snakeCase, kebabCase, and constantCase functions work similarly with their respective conventions.
The key differentiator is number handling. Scule treats numbers as word separators: camelCase('version2') returns 'version2' (keeping the number attached), while camelCase('get2FA') returns 'get2FA' (preserving the acronym). Lodash's camelCase would produce 'version2' and 'get2Fa' respectively, losing the intended meaning.
Scule also provides a split function that tokenizes a string into words, which is useful for implementing custom formatting. The upperFirst and lowerFirst functions capitalize or lowercase the first character. All functions accept both strings and arrays of strings for batch conversion.
import { camelCase, pascalCase, snakeCase, kebabCase, constantCase } from 'scule';
camelCase('user-profile'); // 'userProfile'
camelCase('parseJSON'); // 'parseJSON' (preserves acronym)
camelCase('get2FA'); // 'get2FA' (preserves number-acronym)
pascalCase('user profile'); // 'UserProfile'
snakeCase('UserProfile'); // 'user_profile'
kebabCase('user_profile'); // 'user-profile'
constantCase('user profile'); // 'USER_PROFILE'
Development Utilities
perfect-debounce — A properly typed debounce implementation that handles leading/trailing calls, cancellation, and promise flushing. Unlike naive implementations, it preserves the return type and supports async functions correctly.
Debouncing is a common pattern that delays function execution until after a specified wait period since the last invocation. Perfect-debounce provides a type-safe implementation that works correctly with async functions, which is harder than it sounds. Most naive debounce implementations lose the return type information or return undefined for async functions because the return value comes from a future invocation.
The debounce function accepts a function and a delay in milliseconds. It returns a debounced version with the same TypeScript signature as the original function. The returned function returns a promise that resolves when the debounced function finally executes, so callers can await the result. The .cancel() method on the debounced function prevents the pending invocation from executing.
The leading option calls the function on the leading edge of the timeout (immediately on first call) rather than the trailing edge (after the wait period). The maxWait option limits the maximum delay before the function is called, ensuring progress even with continuous calls. These options are useful for search-as-you-type inputs where you want immediate feedback for the first keystroke but debouncing thereafter.
import { debounce } from 'perfect-debounce';
// Type-safe debounce with proper async support
const search = async (query: string) => {
const results = await fetch(`/api/search?q=${query}`);
return results.json();
};
const debouncedSearch = debounce(search, 300);
// The return type is preserved: Promise<SearchResult[]>
const results = await debouncedSearch('user input');
// Cancel pending invocation
debouncedSearch.cancel();
// Leading edge option: fires immediately then debounces
const immediateSearch = debounce(search, 300, { leading: true });
consola — Universal console logger with level-based filtering, JSON output, and fancy reporters. Works in Node.js, browsers, and workers. Drop-in replacement for console.log with better DX — colorized output, badge support, and silent mode for tests.
Consola is a universal logger that replaces console.log, console.warn, and console.error with a richer interface that works across Node.js, browsers, and web workers. Import consola and use consola.log, consola.warn, consola.error, consola.info, consola.success, consola.debug, and consola.trace — each with appropriate color coding, badges, and formatting for the log level.
The log level system filters output based on the current environment. In development, show all levels including debug. In production, show only warn, error, and fatal. In tests, use silent mode to suppress all output. The level is set via consola.level property or the CONSOLA_LEVEL environment variable. This eliminates the need for ad-hoc if (debug) conditionals throughout your code.
Consola supports custom reporters for different output formats. The built-in FancyReporter provides colorized, badge-formatted output for interactive terminals. The JSONReporter outputs structured JSON for logging systems like ELK or Datadog. The BasicReporter provides simple text output for CI environments. You can write custom reporters by implementing the log method interface.
import { consola } from 'consola';
// Level-based logging with automatic formatting
consola.info('Server starting on port', 3000);
consola.success('Database connected');
consola.warn('Rate limit approaching:', '95%');
consola.error(new Error('Connection timeout'));
consola.debug('Request payload:', { body: requestBody });
// Level filtering
consola.level = 3; // Log only warn and above
consola.info('Hidden'); // Not shown
consola.warn('Visible'); // Shown
// JSON output for production
consola.setReporters([new JSONReporter()]);
// Output: {"level":3,"message":"Server started","timestamp":"2026-06-20T..."}
citty — Build beautiful CLI apps with TypeScript-first argument parsing, subcommands, and auto-generated help text. Lighter than Commander, more typed than Yargs. Generates --help output that actually looks good.
Citty is a CLI framework that makes building command-line tools with subcommands ergonomic and type-safe. Define your main command with a meta object (name, version, description), a args object for CLI flags and positional arguments, and a run function that receives the parsed arguments as a typed object. Subcommands are nested defineCommand calls in a subCommands object.
The argument parser supports positional arguments, named flags with short/long forms (--port and -p), value types (string, boolean, number), default values, required flags, and argument conflicts. Each argument gets a description that appears in the auto-generated --help output. The help text is formatted with aligned columns, colored section headers, and usage examples.
Citty generates shell completion scripts automatically. Run my-cli completion to output a shell script for bash, zsh, or fish that provides tab completion for all commands, subcommands, and flag options. This is typically set up by the user adding eval "$(my-cli completion)" to their shell rc file.
import { defineCommand, runMain } from 'citty';
const main = defineCommand({
meta: {
name: 'build-tool',
version: '1.0.0',
description: 'A modern build tool',
},
args: {
input: {
type: 'positional',
description: 'Input file path',
required: true,
},
output: {
type: 'string',
alias: 'o',
description: 'Output directory',
default: 'dist',
},
watch: {
type: 'boolean',
alias: 'w',
description: 'Watch for changes',
},
port: {
type: 'number',
alias: 'p',
description: 'Dev server port',
default: 3000,
},
},
run({ args }) {
// args.input is typed as string
// args.output is typed as string with default 'dist'
// args.watch is typed as boolean with default false
console.log(`Building ${args.input} -> ${args.output}`);
},
});
runMain(main);
hookable — Lightweight hook system for lifecycle events with before/after, parallel, and sequential execution. Lets you add plugins and middleware to any application without coupling. The hook system behind Nuxt and unjs tools.
Hookable provides an event emitter-like system designed for plugin architectures. Create a hookable instance with createHooks() or extend a class with Hooksable. Define named hooks that plugins can tap into. When your application reaches a lifecycle point, call the hook and all registered listeners execute in order.
The key difference from Node.js EventEmitter is the lifecycle model. Hooks support before and after naming conventions, sequential and parallel execution, and async/await natively. A hook can return a value that gets passed to the next hook in the chain (waterfall pattern). Hooks can also be deprecated with migration warnings when the API evolves.
Hookable supports TypeScript generics for type-safe hook definitions. Define the hook map interface with event names and their payload types. Plugin authors get full type inference when registering hooks, preventing runtime errors from mismatched payload structures. The tap method adds a hook, callHook triggers it, and removeHook removes it for cleanup.
import { createHooks } from 'hookable';
interface Hooks {
'build:start': (options: { input: string }) => void | Promise<void>;
'build:end': (result: { output: string }) => void | Promise<void>;
}
const hooks = createHooks<Hooks>();
// Register a hook
hooks.hook('build:start', async ({ input }) => {
console.log(`Building ${input}...`);
});
hooks.hook('build:end', ({ output }) => {
console.log(`Built to ${output}`);
});
// Trigger hooks during build
async function build(input: string) {
await hooks.callHook('build:start', { input });
const output = `dist/${input}`;
await hooks.callHook('build:end', { output });
return output;
}
defu — Deep object merging with array handling and circular reference protection. Unlike Object.assign or spread, it merges nested objects recursively and supports custom merge strategies for different property types.
Defu performs deep merging of JavaScript objects, handling nested structures that Object.assign and the spread operator cannot manage. defu(defaults, overrides) returns a new object where all nested properties from both arguments are merged recursively. Properties from later arguments override earlier ones, but nested objects are merged rather than replaced.
The function handles edge cases that break naive recursive merge implementations. Arrays are replaced by default (not concatenated), which is the expected behavior for configuration merging. Circular references are detected and handled without stack overflow. undefined values in overrides do not override defined values in the defaults, which is useful for optional configuration fields.
Defu supports custom merge strategies via the defu.fn submodule. You can provide a function that determines how specific property types are merged. For example, merge arrays by concatenation instead of replacement, or merge functions by creating wrapper functions that call both. The default strategy covers 90% of use cases without customization.
import { defu } from 'defu';
const defaults = {
server: {
port: 3000,
host: 'localhost',
},
database: {
host: 'localhost',
pool: {
min: 2,
max: 10,
},
},
features: ['logging', 'metrics'],
};
const overrides = {
server: {
port: 4000,
},
database: {
pool: {
max: 20,
},
},
};
const config = defu(overrides, defaults);
// Result: server.port=4000, database.pool.min=2, database.pool.max=20
// Arrays and primitives from defaults are preserved where not overridden
Error Handling & Data
serialize-error — Converts Error objects to plain serializable objects, preserving the stack trace, name, and custom properties. Essential for logging errors that cross process boundaries, get sent over the network, or stored in databases.
JavaScript Error objects are not serializable by JSON.stringify — they produce {} because Error properties are non-enumerable. Serialize-error solves this by extracting all relevant properties from an Error and returning a plain object that can be serialized, sent over HTTP, stored in a database, or logged to a structured logging system.
The serializeError function extracts the name, message, stack, cause, and any custom properties attached to the error. The deserializeError function converts the plain object back into a proper Error instance with the correct prototype chain. This round-trip is useful for error handling in Distributed Systems where an error thrown in one service is serialized, sent over the network, and re-thrown in another service.
Serialize-error handles nested errors and aggregate errors. The cause property (Error.cause from ES2022) is serialized recursively. The errors property from AggregateError instances is serialized as an array of serialized errors. Custom error classes with additional properties are preserved as long as those properties are enumerable or included in the toJSON method.
import { serializeError, deserializeError } from 'serialize-error';
class AppError extends Error {
constructor(message: string, public code: number) {
super(message);
this.name = 'AppError';
}
}
const error = new AppError('Database connection failed', 500);
// Serialize for logging or network transmission
const serialized = serializeError(error);
// Result: { name: 'AppError', message: 'Database connection failed',
// stack: 'AppError: ...', code: 500 }
// Deserialize back to Error instance
const restored = deserializeError(serialized);
console.log(restored instanceof AppError); // true
console.log(restored.code); // 500
// Safe JSON serialization
const safeJSON = JSON.stringify(serializeError(error));
pkg-types — Resolve package.json fields, detect package manager, and read package metadata with full ESM/CJS support. Knows the difference between exports, main, module, and types fields and resolves them correctly.
Pkg-types is a utility library for working with package.json files and Node.js module resolution. Its primary function, resolvePackageJSON, finds the nearest package.json file for a given module path. The readPackageJSON function reads and parses a package.json file, resolving the export conditions correctly for the current environment (ESM vs CJS, browser vs Node).
The package manager detection feature identifies which package manager a project uses by checking for lock files (package-lock.json, yarn.lock, pnpm-lock.yaml) and reading the packageManager field from package.json. This is useful for tools that need to delegate to the correct package manager for script execution or dependency installation.
The export resolution logic handles the Node.js exports field correctly, which is notoriously complex. It resolves conditions like import, require, node, browser, and default in the correct priority order, matching Node.js's own algorithm. This ensures that tools using pkg-types resolve module paths the same way Node.js does.
import { resolvePackageJSON, readPackageJSON, detectPackageManager } from 'pkg-types';
// Find the nearest package.json
const pkgPath = await resolvePackageJSON('./src/index.ts');
// Returns: '/path/to/project/package.json'
// Read and parse package.json with export resolution
const pkg = await readPackageJSON();
console.log(pkg.name); // 'my-package'
console.log(pkg.exports); // Resolved export map
// Detect package manager
const pm = await detectPackageManager();
console.log(pm); // { name: 'pnpm', version: '9.0.0' }
Practice Questions
- What is the main advantage of Zod over writing TypeScript interfaces and runtime validation separately?
- Why would you choose tsup over
tscfor building a TypeScript library? - How does destr handle a string like
"true"differently fromJSON.parse("true")? - What problem does serialize-error solve that
JSON.stringify(error)cannot handle? - When would you use defu instead of the spread operator (
{...a, ...b}) for merging objects?
Answers
- Zod infers TypeScript types automatically from the schema definition, eliminating the duplication of maintaining both an interface and a validation function.
- Tsup is 10-100x faster than tsc because it uses esbuild (Go-based) instead of the TypeScript compiler, and it handles CJS/ESM dual output automatically.
- Both return
truefor the booleantrue, but destr is safer for arbitrary user input because it does not throw on malformed input and handlesnull,undefined, andNaNstrings thatJSON.parsecannot handle. JSON.stringify(error)returns{}because Error properties are non-enumerable. serialize-error extractsname,message,stack,cause, and custom properties into a serializable plain object.- Use defu when you need deep merging of nested objects (e.g., configuration with nested settings). The spread operator only merges top-level properties, replacing entire nested objects instead of merging them.
Mini Project: Build a Configuration-Driven CLI Tool
Combine citty, c12, zod, consola, and defu into a CLI tool that reads a config file, validates it with Zod, merges defaults with defu, and provides structured logging with consola. The citty CLI definition provides argument parsing and help text. C12 loads the config from multiple sources (file, env, CLI flags). Zod validates the merged config at startup. Consola provides formatted output throughout the tool's lifecycle.
import { defineCommand, runMain } from 'citty';
import { loadConfig } from 'c12';
import { z } from 'zod';
import { defu } from 'defu';
import { consola } from 'consola';
const ConfigSchema = z.object({
port: z.number().int().min(1).max(65535).default(3000),
host: z.string().default('localhost'),
database: z.object({
url: z.string().url(),
pool: z.object({
min: z.number().default(2),
max: z.number().default(10),
}).default({}),
}),
});
const main = defineCommand({
meta: { name: 'my-app', version: '1.0.0', description: 'CLI tool' },
args: {
config: { type: 'string', alias: 'c', description: 'Config file path' },
port: { type: 'number', alias: 'p', description: 'Server port' },
},
async run({ args }) {
const { config: fileConfig } = await loadConfig({
name: 'my-app',
overrides: { port: args.port },
});
const defaults = { port: 3000, host: 'localhost', database: { pool: { min: 2, max: 10 } } };
const merged = defu(fileConfig, defaults);
const parsed = ConfigSchema.parse(merged);
consola.info('Starting server on', `${parsed.host}:${parsed.port}`);
},
});
runMain(main);
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro