Architecture
High-level overview of how maltty is structured, its design philosophy, and how data flows through the system.
Overview
maltty is a CLI framework for building composable, type-safe command-line tools. It provides a modular architecture with commands, middleware, context, and config layers that combine to build CLIs with full type inference and a rich terminal UI.
The codebase follows a functional, immutable, composition-first design. There are no classes, no let, no throw statements, and no loops. Errors are returned as Result tuples. Side effects (process exit, terminal output) are pushed to the outermost edges.
Package Ecosystem
Layers
Entry Layer
Package: packages/cli
The CLI binary entrypoint. Calls cli() from maltty with the CLI name, version, commands, and middleware. This is the only layer that reads package.json for version and calls process.exit.
Core Layer
Package: packages/maltty/src/
The framework primitives:
Lib Layer
Package: packages/maltty/src/lib/
Shared utilities consumed by the core and extension layers:
Context
The Context is the central object threaded through every middleware and command handler. It carries all request-scoped data and utilities for a single CLI invocation.
All data properties (args, config, meta) are deeply readonly at the type level. The store is the only mutable property -- it exists for middleware-to-handler data flow.
Module Augmentation
Consumers extend the context type system via declaration merging without threading generics:
After augmentation, ctx.args.verbose, ctx.config.apiUrl, and ctx.store.get('auth') are fully typed across all commands and middleware.
Data Flow
A CLI invocation flows through the system in this order:
Step-by-step
- Parse argv -- Yargs parses
process.argvand matches a registered command - Clean args -- Internal yargs keys (
_,$0, dashed duplicates) are stripped - Validate args -- If the command defines a Zod schema, args are validated against it
- Load config -- Config file (
.{name}.jsonc,.json, or.yaml) is discovered, parsed, and validated - Create context --
createContext()assembles store, format, colors, log, prompts, spinner, errors, and meta - Run middleware -- Root middleware wraps command middleware in an onion model; each calls
next()to continue - Execute handler -- The matched command's handler runs with the fully constructed context
- Exit --
ContextErrorcaught at the CLI boundary produces a clean exit with code; success exits 0
Command System
Commands are created with the command() factory:
Commands support nested subcommands via the commands property, which accepts either a static CommandMap or a Promise<CommandMap> from autoload() for lazy loading.
Middleware System
Middleware wraps command execution with pre/post logic. Created with the middleware() factory:
Middleware follows an onion model: root middleware (from cli()) wraps command middleware (from command()), which wraps the handler. Each middleware calls next() to pass control inward. Data flows between middleware and handlers via ctx.store. See Lifecycle for the full execution model.
Autoloader
The autoload() function discovers command files from a directory:
Discovery rules:
- Files must export a default
Command(created viacommand()) - Extensions:
.tsor.js(not.d.ts) - Ignored: files starting with
_or., files namedindex - Subdirectories become parent commands;
index.tsin a subdirectory becomes the parent handler
Config System
The config client discovers, parses, and validates config files:
Search order:
- Custom
searchPaths(if provided) - Current working directory
- Project root (detected via
.git)
File formats: .{name}.jsonc, .{name}.json, .{name}.yaml
Config is validated against a Zod schema and errors are returned as discriminated ConfigError unions (ConfigParseError or ConfigValidationError).
Error Handling
maltty uses two error strategies depending on the layer:
Result tuples are used for expected failures (config parsing, validation, file I/O). Chain with early returns:
ContextError is used for user-facing errors via ctx.fail(). It is the only thrown type, and is caught at the CLI boundary for clean exit handling.
Design Decisions
- Immutable by default -- All context properties are deeply readonly; only
ctx.storeis mutable (for middleware data flow) - Factories over classes -- All components are factory functions returning plain objects
- Result tuples over throw -- Expected failures use
Result<T, E>;ContextErroris the only thrown type at the CLI boundary - Module augmentation --
MalttyArgs,CliConfig,MalttyStoreinterfaces allow typed extensions without generics threading - Discriminated unions -- Domain types use
typefields or symbol-based tags for exhaustive pattern matching viats-pattern - Lazy subcommand loading -- Commands accept
Promise<CommandMap>fromautoload()for deferred imports - Zod at boundaries -- Runtime config, args, and external data validated with Zod schemas
- Sensitive data redaction -- Deep object redaction and regex pattern sanitization built into the context
Package Conventions
All packages in this monorepo follow strict conventions to ensure consistency, type safety, and modern JavaScript practices.
Module System
ESM Only:
- All packages use
"type": "module"inpackage.json - No CommonJS (
require,module.exports) - All imports use ESM syntax (
import/export)
Build Configuration
tsdown:
- All packages built with tsdown
- Configuration:
dts: true,format: 'esm',clean: true,outDir: 'dist' - Generates
.jsfiles and.d.tsdeclaration files - Tree-shakeable by default
TypeScript Configuration
Strict Mode:
Key Settings:
target: ES2022— Modern JavaScript features (top-level await, class fields, etc.)module: ESNext— Latest module syntaxmoduleResolution: bundler— Optimized for bundlers (tsdown, vite, etc.)strict: true— All strict checks enabledisolatedDeclarations: true— Critical: Forces explicit return types on all exported functions
Test Structure
Vitest Workspace:
- Root
vitest.config.tsdefines workspace - Unit tests: Colocated in
src/**/*.test.tsalongside source files - Integration tests:
test/integration/*.test.tsat package root - Coverage thresholds defined per package
Example Structure:
Immutability Requirement
All Public Properties readonly:
- All exported interfaces/types must have
readonlymodifiers - Deep immutability enforced with
DeepReadonly<T>from type-fest - Prevents accidental mutation of shared objects
Example:
Config Validation
Zod at Boundaries:
- All config files validated with Zod schemas
- All CLI arguments validated with Zod
- Runtime validation at system boundaries (file I/O, user input)
Explicit Return Types
Required by isolatedDeclarations:
- All exported functions must have explicit return types
- TypeScript compiler will error without them
- Ensures declaration files can be generated without full type inference
Example:
Package Naming
Convention:
- Scope:
@maltty/ - Name: Lowercase, single word or hyphenated (e.g.,
maltty,@maltty/cli)
Package Structure
Standard Layout: