Build a complete, production-quality TypeScript CLI tool: argument parsing, file processing, HTTP API client, typed configuration, error handling, and a test suite — tying together all concepts from Labs 01–14.
Background
This capstone builds a prodctl CLI tool — a product catalog manager that reads/writes JSON files and calls a REST API. You'll apply: interfaces (Lab 3), classes (Lab 4), generics (Lab 6), modules (Lab 7), error handling (Lab 8), async patterns (Lab 9), type manipulation (Lab 10), Node.js (Lab 12), and testing (Lab 13).
Time
60 minutes
Prerequisites
Labs 01–14
Tools
Docker image: zchencow/innozverse-ts:latest
Lab Instructions
Step 1: Project Types & Interfaces
💡 Omit<Product, 'id' | 'createdAt' | 'updatedAt'> creates the create/update DTOs automatically from the main interface. When you add a field to Product, you don't need to update the DTO types manually — they stay in sync. This is the DRY principle applied at the type level.
📸 Verified Output:
Step 2: Data Store
📸 Verified Output:
Step 3: CLI Parser
📸 Verified Output:
Step 4: Output Formatters
📸 Verified Output:
Step 5: Command Implementations
📸 Verified Output:
Step 6: Error Handling & Validation
📸 Verified Output:
Step 7: Test Suite
📸 Verified Output:
Step 8: Complete CLI — Main Entry Point
💡 Record<string, (args: ParsedArgs, store: Store) => Promise<void>> is the command registry type — a dictionary of async command handlers. Looking up a command with COMMANDS[parsed.command] is O(1) and type-safe. Adding a new command requires only one entry — no if/switch statements.
📸 Verified Output:
Verification
Summary
You've built a complete, production-quality TypeScript CLI tool using every concept from Labs 01–14:
=== TABLE FORMAT ===
ID Name Price Stock Category Status
─────────────────────────────────────────────────────────────────────────
1 Surface Pro 12" 799.99 15 Laptop active
2 Surface Pen 49.99 80 Accessory active
3 USB-C Hub 29.99 0 Accessory out_of_stock
// list command
async function listCommand(store: JsonStore<Product>, flags: Record<string, string | boolean>): Promise<void> {
const opts: ListOptions = {
filter: flags.category ? { category: flags.category as string } : undefined,
search: flags.search as string | undefined,
sort: flags.sort ? { field: flags.sort as SortField, order: (flags.order ?? "asc") as SortOrder } : undefined,
limit: flags.limit ? parseInt(flags.limit as string) : undefined,
};
const products = store.findAll(opts);
const format = (flags.format as string) ?? "table";
if (format === "json") console.log(formatJson(products));
else if (format === "csv") console.log(formatCsv(products));
else console.log(formatTable(products));
console.log(`\n${products.length} product(s)`);
}
// get command
async function getCommand(store: JsonStore<Product>, id: number): Promise<void> {
const product = store.findById(id);
if (!product) { console.error(`Error: Product #${id} not found`); return; }
console.log(JSON.stringify(product, null, 2));
}
// create command
async function createCommand(store: JsonStore<Product>, data: Partial<ProductCreate>): Promise<void> {
const required = ["name", "price", "stock", "category"] as const;
const missing = required.filter(f => data[f] == null);
if (missing.length) { console.error(`Error: Missing fields: ${missing.join(", ")}`); return; }
const product = store.create({
name: data.name!,
price: Number(data.price),
stock: Number(data.stock),
category: data.category!,
status: "active",
tags: data.tags,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
console.log(`Created: #${product.id} ${product.name}`);
}
// Run simulated commands
const cmdStore = new JsonStore<Product>("/tmp/cmd-store.json");
await createCommand(cmdStore, { name: "Surface Pro 12\"", price: 864, stock: 15, category: "Laptop" });
await createCommand(cmdStore, { name: "Surface Pen", price: 49.99, stock: 80, category: "Accessory" });
await createCommand(cmdStore, { name: "Office 365", price: 99.99, stock: 999, category: "Software" });
console.log("\n--- List all ---");
await listCommand(cmdStore, {});
console.log("\n--- Filter by category ---");
await listCommand(cmdStore, { category: "Accessory" });
Created: #1 Surface Pro 12"
Created: #2 Surface Pen
Created: #3 Office 365
--- List all ---
[table of all 3 products]
--- Filter by category ---
[table of Surface Pen only]
class CliError extends Error {
constructor(message: string, public readonly exitCode: number = 1) {
super(message);
this.name = "CliError";
}
}
function validateProduct(data: Record<string, unknown>): Result<ProductCreate, CliError> {
const errors: string[] = [];
if (!data.name || typeof data.name !== "string" || data.name.length < 2)
errors.push("name: must be at least 2 characters");
if (!data.price || isNaN(Number(data.price)) || Number(data.price) <= 0)
errors.push("price: must be a positive number");
if (data.stock !== undefined && (isNaN(Number(data.stock)) || Number(data.stock) < 0))
errors.push("stock: must be a non-negative number");
if (!data.category || typeof data.category !== "string")
errors.push("category: required");
if (errors.length > 0) return err(new CliError(`Validation failed:\n ${errors.join("\n ")}`));
return ok({
name: String(data.name),
price: Number(data.price),
stock: Number(data.stock ?? 0),
category: String(data.category),
tags: Array.isArray(data.tags) ? data.tags.map(String) : undefined,
});
}
const cases: Record<string, unknown>[] = [
{ name: "Surface Pro", price: 864, stock: 15, category: "Laptop" },
{ name: "X", price: -10 },
{ name: "Test Product", price: 49.99, category: "Accessory" },
];
cases.forEach((input, i) => {
const result = validateProduct(input);
if (result.ok) console.log(`✓ Case ${i+1}: ${result.value.name} $${result.value.price}`);
else console.log(`✗ Case ${i+1}:\n${result.error.message}`);
});
✓ Case 1: Surface Pro $864
✗ Case 2:
Validation failed:
name: must be at least 2 characters
price: must be a positive number
category: required
✓ Case 3: Test Product $49.99