Build a Type-Safe Task Runner
Learn TypeScript fundamentals by building a typed CLI task runner.
Introduction
30 Second Summary
Think about a checklist you run through before leaving the house. Keys, wallet, phone. If something is missing, you stop and fix it before walking out the door.
In this project, you will build a command-line task runner that works the same way. You will define typed tasks in TypeScript, run them in sequence, and get a clear report of what passed and what failed.
What You'll Build
A TypeScript CLI that models tasks as typed objects with explicit success and failure states, executes them in a pipeline, and prints a colored report to your terminal.
By the end of this project, you'll have:
- A typed task configuration system using interfaces and discriminated unions that forces you to handle every outcome.
- Type guard functions that validate unknown input before it enters the typed world.
- A generic task registry and pipeline that chains tasks together, passing data forward and stopping at the first failure.
- Secret Mission: Add a retry mechanism with configurable attempts and delay using generics.
Are there any prerequisites?
You should have basic JavaScript knowledge, including familiarity with objects and functions. No prior TypeScript experience is required.
This project uses Bun as the runtime and Zed as the code editor. Both should already be installed on your machine.
Set Up Your TypeScript Project with Bun
Before you can write any TypeScript, you need a project folder with the right configuration. Bun handles this for you in a single command, generating a ready-to-run TypeScript file and a strict tsconfig.json that catches bugs at compile time.
In this step, you will scaffold a new Bun project, set up the file structure for your task runner, and confirm that your editor catches type errors before you ever run the code.
In this step, get ready to:
- Initialize a Bun project with TypeScript strict mode.
- Create the source file structure for the task runner.
- Verify your editor highlights type errors in real time.
Initialize a new Bun project and verify the generated files
Bun's bun init command scaffolds a TypeScript project with sensible defaults, including strict type checking enabled out of the box.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your project by running this command:
bun init task-runner -y
What does this command do?
The bun init task-runner -y command creates a new folder called task-runner with several files inside it.
- index.ts is your entry point file that Bun runs.
- tsconfig.json configures the TypeScript compiler with strict mode enabled.
- package.json tracks your project's name and dependencies.
- The -y flag auto-accepts all defaults so you skip the interactive prompts.
- Move into the new project folder by running:
cd task-runner
- Confirm the files were created by running:
ls
- You should see index.ts, tsconfig.json, package.json, and other files in the output.
Now confirm that TypeScript strict mode is enabled. This is what makes TypeScript catch bugs before your code ever runs.
- Open tsconfig.json in Zed and look for the "strict": true line inside compilerOptions.
Why does strict mode matter?
With "strict": true, TypeScript enables a set of checks that force you to handle null values, declare types explicitly, and avoid common JavaScript pitfalls.
Without it, TypeScript is lenient and lets unsafe code slip through. Strict mode is what gives you the real bug-catching power.
- Run the default entry point to confirm everything works:
bun index.ts
- You should see Hello via Bun! printed in your terminal.
Don't see the output?
- Make sure you ran cd task-runner before bun index.ts. The command must run from inside the project folder.
- Check that bun init task-runner -y completed without errors. If it failed, delete the folder and try again.
Help me troubleshoot my bun init setup.
Set up the project file structure
Your task runner will live in a src/ folder with separate files for types, runner logic, and the main entry point. This keeps responsibilities cleanly separated.
- Create the src directory and the three source files by running these commands:
mkdir src
touch src/types.ts src/runner.ts src/main.ts
- Confirm the files exist by running:
ls src
- You should see main.ts, runner.ts, and types.ts in the output.
Next, update index.ts to import from your new src/main.ts file instead of printing a message directly.
- Open index.ts in Zed and replace all of its contents with the following code:
import "./src/main";
What does this import do?
This tells Bun to execute everything inside src/main.ts when you run bun index.ts. Since src/main.ts is empty right now, nothing will happen. You will fill it with your task runner logic in later steps.
- Save index.ts.
- Run bun index.ts in your terminal. It should complete with no output and no errors.
Seeing a module not found error?
- Check that src/main.ts exists. The import path "./src/main" is relative to where index.ts lives.
- Make sure you saved index.ts after editing. Unsaved changes are not picked up by Bun.
Help me fix my module import error.
✔️ Awesome, I've got everything!
Great. Double check you have saved index.ts and that running bun index.ts exits cleanly with no output.
ⓧ I'd like to double check the full code
Here is the complete index.ts file at this point:
import "./src/main";
Your src/types.ts, src/runner.ts, and src/main.ts files should all be empty at this point.
Verify your editor shows type errors
Before writing real code, confirm that Zed catches type errors inline. This is your safety net for the rest of the project.
- Open src/main.ts in Zed.
- Type the following line into the file:
const x: number = "hello";
- Look for a red underline on "hello". Zed should flag this because you are assigning a string to a variable typed as number.
Why does this error appear?
TypeScript's strict mode sees that "hello" is a string, but you declared x as number. These types are incompatible, so TypeScript refuses to compile the file.
This is exactly the kind of bug that TypeScript catches before runtime. Throughout this project, you will rely on these red underlines to confirm your types are correct.
- Delete the const x: number = "hello"; line so the file is empty again.
- Save src/main.ts.
No red underline appearing?
- Make sure you opened the task-runner folder as a project in Zed, not just the single file. TypeScript needs the tsconfig.json to be in scope.
- Check that Zed's TypeScript language server is running. Look for a "TypeScript" indicator in the bottom status bar.
Help me get Zed to show TypeScript errors.
Your project skeleton is ready. Next up, you will define the core types that power your task runner.
Define Your Task Types with Interfaces and Union Types
Your project has empty files waiting to be filled. Before you write any logic, you need to define the shape of the data your task runner works with.
TypeScript's type system lets you describe exactly what a task looks like and what outcomes it can produce. By encoding these shapes as types, TypeScript catches mistakes at compile time instead of letting them crash at runtime.
In this step, get ready to:
- Define a TaskResult discriminated union and Task interface.
- Create three concrete tasks that conform to the Task interface.
- Experience TypeScript catching invalid type usage at compile time.
Define the Task interface and TaskResult discriminated union
A task runner needs to know two things: what a task looks like, and what outcomes a task can produce. You will model both with TypeScript types.
- Open src/types.ts in Zed and add the following type definitions:
export type TaskResult =
| { status: "success"; output: string }
| { status: "failure"; error: string };
export type Context = Record<string, string>;
What is a discriminated union?
A discriminated union is a union type where every member shares a common field with a literal value. Here, status is the discriminant. Its value is either "success" or "failure" and nothing else.
When you later check result.status in a switch or if statement, TypeScript automatically narrows the type so you can safely access .output or .error without any extra checks.
- Below the Context type, add the Task interface and its two specialized extensions:
export interface Task {
name: string;
description: string;
run: (ctx?: Context) => TaskResult;
}
export interface FileTask extends Task {
path: string;
}
export interface ShellTask extends Task {
command: string;
}
What does this code do?
The Task interface defines the contract every task must follow: a name, a description, and a run function that returns a TaskResult. The optional ctx parameter lets tasks share data through a context object.
FileTask and ShellTask extend Task with an extra property. This is how you specialize types without losing the base contract. You will use these specialized types in a later step with generics.
- Save src/types.ts.
- Confirm Zed shows no red underlines in the file. The types are valid and ready to use.
Seeing red underlines in types.ts?
Check that every opening brace { has a matching closing brace } and every pipe | is at the start of each union member line. Also confirm that the run method signature ends with => TaskResult; and not a colon.
help me fix the type errors in my types.ts file
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/types.ts before moving on.
ⓧ I'd like to double check the full code
export type TaskResult =
| { status: "success"; output: string }
| { status: "failure"; error: string };
export type Context = Record<string, string>;
export interface Task {
name: string;
description: string;
run: (ctx?: Context) => TaskResult;
}
export interface FileTask extends Task {
path: string;
}
export interface ShellTask extends Task {
command: string;
}
Create your first concrete tasks using the Task interface
Types describe the shape. Now you will create actual task objects that conform to that shape. Each task returns either a success or failure TaskResult.
- Create a new file src/tasks.ts and add the imports and first task:
import type { Task, Context } from "./types";
import { existsSync } from "node:fs";
export const checkBunVersion: Task = {
name: "check-bun-version",
description: "Verify Bun is running",
run: () => {
const version = Bun.version;
if (version) {
return { status: "success", output: `Bun version: ${version}` };
}
return { status: "failure", error: "Could not determine Bun version" };
},
};
What does this code do?
import type brings in the Task and Context types without generating any runtime JavaScript. The existsSync import from node:fs is a Node-compatible filesystem function that Bun supports natively.
checkBunVersion satisfies the Task interface. It has a name, description, and a run function that returns a TaskResult. The Bun.version API returns the current Bun version string.
- Below checkBunVersion, add the second task that checks whether package.json exists:
export const checkFileExists: Task = {
name: "check-package-json",
description: "Check if package.json exists",
run: () => {
if (existsSync("package.json")) {
return { status: "success", output: "package.json found" };
}
return { status: "failure", error: "package.json not found" };
},
};
What does this code do?
checkFileExists uses existsSync to check for package.json on disk. It returns a typed success or failure result. The structure is identical to the first task, proving that the Task interface enforces a consistent shape.
- Below checkFileExists, add the third task that simulates a disk space check:
export const checkDiskSpace: Task = {
name: "check-disk-space",
description: "Simulate disk space check",
run: () => {
const freeGB = Math.floor(Math.random() * 100) + 10;
if (freeGB > 5) {
return { status: "success", output: `${freeGB}GB free disk space` };
}
return { status: "failure", error: `Only ${freeGB}GB free, need at least 5GB` };
},
};
What does this code do?
checkDiskSpace simulates a disk check using random numbers. Since Math.floor(Math.random() * 100) + 10 always produces a value above 5, this task will always succeed in practice. The important thing is that it still returns a properly typed TaskResult with either a success or failure status.
- Save src/tasks.ts.
- Confirm Zed shows no red underlines. All three tasks satisfy the Task interface.
Now verify the tasks actually run. You will add temporary test code to src/main.ts to call the tasks and print results.
- Open src/main.ts and add the following test code:
import { checkBunVersion, checkFileExists, checkDiskSpace } from "./tasks";
console.log(checkBunVersion.run());
console.log(checkFileExists.run());
console.log(checkDiskSpace.run());
- Save src/main.ts and run the project by executing this command in your terminal:
bun index.ts
- You should see three objects printed, each with a status field and either an output or error field.
Seeing 'Cannot find module' errors?
Make sure the import path in src/main.ts uses "./tasks" (with the dot-slash prefix). Also confirm that src/tasks.ts is saved and exists in the same src/ folder as main.ts.
help me fix a module not found error when importing tasks
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/tasks.ts before moving on.
ⓧ I'd like to double check the full code
import type { Task, Context } from "./types";
import { existsSync } from "node:fs";
export const checkBunVersion: Task = {
name: "check-bun-version",
description: "Verify Bun is running",
run: () => {
const version = Bun.version;
if (version) {
return { status: "success", output: `Bun version: ${version}` };
}
return { status: "failure", error: "Could not determine Bun version" };
},
};
export const checkFileExists: Task = {
name: "check-package-json",
description: "Check if package.json exists",
run: () => {
if (existsSync("package.json")) {
return { status: "success", output: "package.json found" };
}
return { status: "failure", error: "package.json not found" };
},
};
export const checkDiskSpace: Task = {
name: "check-disk-space",
description: "Simulate disk space check",
run: () => {
const freeGB = Math.floor(Math.random() * 100) + 10;
if (freeGB > 5) {
return { status: "success", output: `${freeGB}GB free disk space` };
}
return { status: "failure", error: `Only ${freeGB}GB free, need at least 5GB` };
},
};
Experience the type system catching a bug
You have seen TypeScript accept valid code. Now see what happens when you violate the types.
- In src/tasks.ts, find the checkBunVersion task.
- Temporarily change the success return to be missing the output field:
return { status: "success" };
- Look at Zed. You should see a red underline immediately. TypeScript is telling you that output is required when status is "success".
- Now try returning an invalid status value. Replace that same line with:
return { status: "unknown", message: "???" };
- Zed underlines this too. The union only accepts "success" or "failure" as the status value. Anything else is rejected at compile time.
- Undo both changes so checkBunVersion returns the correct TaskResult objects again.
- Save src/tasks.ts and confirm all red underlines are gone.
Why does this matter?
Without TypeScript, those invalid objects would have been created and passed around silently. You would only discover the bug when the code crashes at runtime. With the discriminated union, TypeScript catches it the instant you type it. No runtime surprises.
This is the core value of a type system: bugs that used to hide until production now show up in your editor within milliseconds of writing them.
Your types are defined, your tasks are built, and TypeScript is actively protecting you from invalid states. Next up, you will write type guard functions that narrow these types at runtime boundaries.
Narrow Types with Type Guards
Your tasks now return a TaskResult union, but how do you safely access .output on a success or .error on a failure? TypeScript will not let you access properties that only exist on one branch of a union unless you prove which branch you are in first.
The answer is type narrowing. In this step, you will write type guard functions that encapsulate the proof in reusable functions, validate unknown input from the outside world, and build a task executor that uses a switch statement for narrowing with colored terminal output.
In this step, get ready to:
- Write type guard functions for TaskResult.
- Validate unknown input with a runtime type guard.
- Build a task executor with exhaustiveness checking and colored output.
Write type guard functions for TaskResult
A type guard is a function that returns a special type predicate. When you call it inside an if block, TypeScript narrows the value to the specific union branch from that point forward.
- Create a new file src/guards.ts in your task-runner project folder.
- Add the following type guard functions:
import type { Task, TaskResult } from "./types";
export function isSuccess(
result: TaskResult
): result is { status: "success"; output: string } {
return result.status === "success";
}
export function isFailure(
result: TaskResult
): result is { status: "failure"; error: string } {
return result.status === "failure";
}
What does this code do?
The return type result is { status: "success"; output: string } is a type predicate. It tells TypeScript: if this function returns true, then result is definitely the success branch.
Without type guards, you would need to check result.status every single time you want to access .output or .error. Type guards let you encapsulate that proof in a reusable function.
- Save src/guards.ts.
- Test these guards by updating src/main.ts with the following code:
import { checkBunVersion } from "./tasks";
import { isSuccess, isFailure } from "./guards";
// Run a task and use type guards to access the result safely
const result = checkBunVersion.run();
if (isSuccess(result)) {
// TypeScript knows result has .output here
console.log("Success:", result.output);
}
if (isFailure(result)) {
// TypeScript knows result has .error here
console.log("Failed:", result.error);
}
- Save src/main.ts and verify the guards work by running:
bun index.ts
You should see a success message printing the Bun version number.
Why is this useful?
Inside the if (isSuccess(result)) block, TypeScript lets you access result.output directly. Outside that block, TypeScript would complain because .output only exists on the success branch. The type guard is your proof.
Don't see any output?
Make sure index.ts still contains import "./src/main"; from the previous step.
Check that you saved both src/guards.ts and src/main.ts before running.
Why isn't my type guard narrowing the result?
Write a type guard for validating unknown input
Type guards are not just for discriminated unions. They can also validate data from the outside world. The unknown type represents values whose shape you cannot trust until you prove it at runtime.
- In src/guards.ts, add the following function below the existing guards:
export function isValidTask(input: unknown): input is Task {
if (typeof input !== "object" || input === null) return false;
const obj = input as Record<string, unknown>;
return (
typeof obj.name === "string" &&
typeof obj.description === "string" &&
typeof obj.run === "function"
);
}
What does this code do?
This function takes a value of type unknown and performs runtime checks to prove it has the right shape. If all checks pass, TypeScript treats the value as a Task from that point on.
This pattern is essential when your program receives data from files, APIs, or user input. You cannot trust the shape at compile time, so you validate it at runtime and let TypeScript narrow the type.
- Save src/guards.ts.
- Test the guard by temporarily updating src/main.ts to pass both valid and invalid objects:
import { checkBunVersion } from "./tasks";
import { isSuccess, isFailure, isValidTask } from "./guards";
// Test isValidTask with different inputs
console.log("Valid task?", isValidTask(checkBunVersion)); // true
console.log("Valid task?", isValidTask({ name: "test" })); // false - missing fields
console.log("Valid task?", isValidTask(42)); // false - not an object
// Run a task with guards
const result = checkBunVersion.run();
if (isSuccess(result)) {
console.log("Success:", result.output);
}
if (isFailure(result)) {
console.log("Failed:", result.error);
}
- Save src/main.ts and run:
bun index.ts
You should see true for the first check and false for the other two, followed by the task result.
Why use unknown instead of any?
With any, TypeScript gives up and lets you access anything without checking. With unknown, TypeScript forces you to prove the shape before you can use it. This makes unknown the safe choice for untyped external data.
Seeing a type error on the cast?
Make sure the import at the top of src/guards.ts uses import type { Task, TaskResult } and that both types are still exported from src/types.ts.
Help me fix a type error in my isValidTask guard function.
Build a task executor with exhaustiveness checking
Now you will see a different approach to narrowing. Instead of calling type guard functions, a switch statement on the discriminant property narrows the type automatically in each case branch.
- Open src/runner.ts and add the following code:
import type { Task, TaskResult, Context } from "./types";
import { isSuccess, isFailure } from "./guards";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
export function executeTask(task: Task, ctx?: Context): void {
const result = task.run(ctx);
switch (result.status) {
case "success":
console.log(`${GREEN}[PASS]${RESET} ${task.name}: ${result.output}`);
break;
case "failure":
console.log(`${RED}[FAIL]${RESET} ${task.name}: ${result.error}`);
break;
default: {
const _exhaustive: never = result;
throw new Error(`Unhandled status: ${JSON.stringify(_exhaustive)}`);
}
}
}
What does this code do?
- The ANSI escape codes (\x1b[32m, \x1b[31m, etc.) are special sequences that tell your terminal to change text color. Green for pass, red for fail.
- Inside case "success":, TypeScript automatically narrows result to the success branch. You can access result.output without any extra checks.
- The default case assigns result to never. Since both branches are handled, result is already narrowed to never here. If you add a third status to TaskResult in the future, TypeScript will throw a compile error at this line until you handle it.
What is exhaustiveness checking?
The never type means "this should be impossible to reach." By assigning result to a never variable in the default case, you create a compile-time safety net.
If someone adds { status: "skipped"; reason: string } to TaskResult later, this line immediately shows an error because the new status cannot be assigned to never. This forces you to handle every possible case.
- Save src/runner.ts.
- Now update src/main.ts to run all three tasks through the executor:
import { executeTask } from "./runner";
import { checkBunVersion, checkFileExists, checkDiskSpace } from "./tasks";
// Run all tasks through the executor
executeTask(checkBunVersion);
executeTask(checkFileExists);
executeTask(checkDiskSpace);
- Save src/main.ts and run:
bun index.ts
You should see colored output with [PASS] in green for each task that succeeds and [FAIL] in red for any that fail.
Not seeing colors in your terminal?
Make sure the escape sequences use \x1b (a backslash, then the letter x, then 1b). If you copy from a rich text source, the escape character can get corrupted.
If your terminal shows raw escape codes like [32m[PASS], your terminal emulator may not support ANSI colors. Most modern terminals (including the Zed integrated terminal) do support them.
Help me get colored output working in my terminal.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full src/guards.ts:
import type { Task, TaskResult } from "./types";
export function isSuccess(
result: TaskResult
): result is { status: "success"; output: string } {
return result.status === "success";
}
export function isFailure(
result: TaskResult
): result is { status: "failure"; error: string } {
return result.status === "failure";
}
export function isValidTask(input: unknown): input is Task {
if (typeof input !== "object" || input === null) return false;
const obj = input as Record<string, unknown>;
return (
typeof obj.name === "string" &&
typeof obj.description === "string" &&
typeof obj.run === "function"
);
}
Here is the full src/runner.ts:
import type { Task, TaskResult, Context } from "./types";
import { isSuccess, isFailure } from "./guards";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
export function executeTask(task: Task, ctx?: Context): void {
const result = task.run(ctx);
switch (result.status) {
case "success":
console.log(`${GREEN}[PASS]${RESET} ${task.name}: ${result.output}`);
break;
case "failure":
console.log(`${RED}[FAIL]${RESET} ${task.name}: ${result.error}`);
break;
default: {
const _exhaustive: never = result;
throw new Error(`Unhandled status: ${JSON.stringify(_exhaustive)}`);
}
}
}
Here is the current src/main.ts:
import { executeTask } from "./runner";
import { checkBunVersion, checkFileExists, checkDiskSpace } from "./tasks";
// Run all tasks through the executor
executeTask(checkBunVersion);
executeTask(checkFileExists);
executeTask(checkDiskSpace);
Your type guards and executor are working. Next up, you will build a generic task registry that can store and retrieve different task types while preserving their specific type information.
Add a Generic Task Registry
Your tasks work. Your type guards work. But right now, tasks are scattered as individual variables with no central place to manage them.
In this step, you will build a generic TaskRegistry class that stores and retrieves tasks by name. The generic constraint <T extends Task> ensures the registry only accepts Task-compatible objects while preserving the specific type you put in.
In this step, get ready to:
- Build a generic TaskRegistry class that stores any Task-compatible type.
- See how generic constraints prevent type mixing at compile time.
- Create a runAll function that executes every task in a registry.
Create a generic Registry class
A registry gives your tasks a home. Instead of passing individual task variables around, you register them once and retrieve them by name.
- Create a new file called src/registry.ts and add the following code:
import type { Task } from "./types";
export class TaskRegistry<T extends Task> {
private tasks: Map<string, T> = new Map();
register(task: T): void {
this.tasks.set(task.name, task);
}
get(name: string): T | undefined {
return this.tasks.get(name);
}
list(): string[] {
return Array.from(this.tasks.keys());
}
getAll(): T[] {
return Array.from(this.tasks.values());
}
}
What does this code do?
The <T extends Task> part is a generic constraint. It means T can be any type, as long as it has the same shape as Task (a name, description, and run method).
The Map<string, T> stores tasks keyed by their name. When you call get(), TypeScript knows the return type is T (your specific task type), not just a generic Task. This is what "type preservation" means in practice.
- Save src/registry.ts.
- Confirm Zed shows no red underlines in the file.
Seeing a type error on the import?
Make sure the import uses import type { Task } (with the type keyword). Your tsconfig.json has verbatimModuleSyntax enabled, which requires type-only imports for interfaces.
Why is my import showing an error in registry.ts?
✔️ Awesome, I've got everything!
Great. Double check you have saved src/registry.ts.
ⓧ I'd like to double check the full code
import type { Task } from "./types";
export class TaskRegistry<T extends Task> {
private tasks: Map<string, T> = new Map();
register(task: T): void {
this.tasks.set(task.name, task);
}
get(name: string): T | undefined {
return this.tasks.get(name);
}
list(): string[] {
return Array.from(this.tasks.keys());
}
getAll(): T[] {
return Array.from(this.tasks.values());
}
}
Create specialized task types and register them
Your src/types.ts already defines FileTask and ShellTask interfaces that extend Task with additional properties. Now you will see how creating typed registries preserves those extra properties.
- In src/main.ts, add the following code to experiment with typed registries:
import { TaskRegistry } from "./registry";
import type { FileTask, ShellTask } from "./types";
// Create a registry that ONLY accepts FileTasks
const fileRegistry = new TaskRegistry<FileTask>();
// Create a registry that ONLY accepts ShellTasks
const shellRegistry = new TaskRegistry<ShellTask>();
// This works - it's a valid ShellTask
const listFiles: ShellTask = {
name: "list-files",
command: "ls",
description: "List files in directory",
run: () => ({ status: "success", output: "files listed" }),
};
shellRegistry.register(listFiles);
// Try this: register a ShellTask in the FileTask registry
fileRegistry.register(listFiles);
What should I see?
Zed underlines the last line fileRegistry.register(listFiles) in red. The error says that ShellTask is not assignable to FileTask because it is missing the path property.
This is generics in action. The <T extends Task> constraint means the registry accepts anything that is at least a Task. But once you specify TaskRegistry<FileTask>, it locks down to only accept FileTask objects. TypeScript catches the mismatch at compile time.
- After observing the error, delete all the code in src/main.ts. You will replace it with working code in the next substep.
Build the final runner that uses the registry
Now you will connect the registry to a runner function that executes all registered tasks and reports results.
- In src/runner.ts, add the import for TaskRegistry at the top of the file. Find the existing import lines:
import type { Task, TaskResult, Context } from "./types";
import { isSuccess, isFailure } from "./guards";
- Add this import directly below them:
import { TaskRegistry } from "./registry";
- Scroll to the bottom of src/runner.ts (after the executeTask function) and add the runAll function:
export function runAll(registry: TaskRegistry<Task>): TaskResult[] {
const tasks = registry.getAll();
const results: TaskResult[] = [];
for (const task of tasks) {
const result = task.run();
results.push(result);
}
return results;
}
What does this code do?
The runAll function accepts any TaskRegistry<Task>. It pulls out every registered task with getAll(), calls run() on each one, and collects all results into an array.
Because TaskRegistry<FileTask> and TaskRegistry<ShellTask> both satisfy TaskRegistry<Task>, you can pass any specialized registry into runAll. This is the power of the extends constraint working in reverse.
- Save src/runner.ts.
Error on the TaskRegistry import?
Make sure you are importing from "./registry" (not "./types"). The TaskRegistry class lives in src/registry.ts, and this is a value import (not a type import) because you use the class at runtime.
Help me fix my TaskRegistry import in runner.ts
✔️ Awesome, I've got everything!
Great. Double check you have saved src/runner.ts.
ⓧ I'd like to double check the full code
import type { Task, TaskResult, Context } from "./types";
import { isSuccess, isFailure } from "./guards";
import { TaskRegistry } from "./registry";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
export function executeTask(task: Task, ctx?: Context): void {
const result = task.run(ctx);
switch (result.status) {
case "success":
console.log(`${GREEN}[PASS]${RESET} ${task.name}: ${result.output}`);
break;
case "failure":
console.log(`${RED}[FAIL]${RESET} ${task.name}: ${result.error}`);
break;
default: {
const _exhaustive: never = result;
throw new Error(`Unhandled status: ${JSON.stringify(_exhaustive)}`);
}
}
}
export function runAll(registry: TaskRegistry<Task>): TaskResult[] {
const tasks = registry.getAll();
const results: TaskResult[] = [];
for (const task of tasks) {
const result = task.run();
results.push(result);
}
return results;
}
Now wire everything together in src/main.ts so you can run the full registry-based pipeline.
- Replace the contents of src/main.ts with the following code:
import { TaskRegistry } from "./registry";
import { runAll } from "./runner";
import { isSuccess, isFailure } from "./guards";
import {
checkBunVersion,
checkFileExists,
checkDiskSpace,
} from "./tasks";
// Create a registry and register all tasks
const registry = new TaskRegistry();
registry.register(checkBunVersion);
registry.register(checkFileExists);
registry.register(checkDiskSpace);
console.log("Registered tasks:", registry.list());
// Run all tasks and collect results
const results = runAll(registry);
// Print summary
const passed = results.filter(isSuccess).length;
const failed = results.filter(isFailure).length;
console.log(`\nTotal: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
What does this code do?
This wires up the entire pipeline. You create a TaskRegistry, register your three tasks, call runAll to execute them all, then filter results using your type guard functions (isSuccess and isFailure) to print a summary.
Notice how new TaskRegistry() works without a type argument here. TypeScript infers the type from what you register. You could also write new TaskRegistry<Task>() to be explicit.
- Save src/main.ts.
- Run the full pipeline by running this command:
bun index.ts
You should see the list of registered task names, followed by a summary showing total tasks, passed count, and failed count.
Seeing "Cannot find module" errors?
Make sure src/registry.ts exists and is saved. The import in src/runner.ts and src/main.ts both reference "./registry" which resolves to src/registry.ts.
Help me fix my module import errors when running bun index.ts
Your registry is working. Next up, you will chain tasks into a pipeline that passes data between them using function composition.
Chain Tasks with Function Composition
Your generic registry gives you a clean, type-safe container for storing and retrieving tasks. Now it's time to make those tasks work together as a coordinated sequence.
In real build systems and CI pipelines, tasks don't run in isolation. They form a chain where one task's failure stops everything downstream. In this step, you'll build that same pattern using function composition and a shared context object that lets tasks pass data to each other.
In this step, get ready to:
- Create a pipeline function that runs tasks in sequence and stops at the first failure.
- Add context-aware tasks that pass data between each other.
- Wire everything into a final CLI that prints a formatted report.
Create a pipeline function that chains tasks in sequence
The runAll function you built earlier runs every task regardless of failures. A pipeline is different. It should stop immediately when something goes wrong, because downstream tasks depend on earlier ones succeeding.
- In src/runner.ts, add the following pipeline function below the executeTask function:
export function pipeline(tasks: Task[], ctx?: Context): TaskResult[] {
const results: TaskResult[] = [];
for (const task of tasks) {
const result = task.run(ctx);
results.push(result);
if (isFailure(result)) {
console.log(`${RED}[STOP]${RESET} Pipeline stopped at "${task.name}": ${result.error}`);
break;
}
}
return results;
}
What does this code do?
- pipeline takes an array of tasks and an optional Context object, then iterates through each task one by one.
- After each task runs, it checks the result using isFailure. If any task fails, the pipeline prints a [STOP] message and breaks out of the loop immediately.
- The function returns all collected results up to and including the failure, so the caller can inspect exactly what happened.
- Save src/runner.ts.
You won't see a change yet. The next chunk adds context-aware tasks that the pipeline will execute.
Add a context object that passes data between tasks
Right now, each task is self-contained. In a real pipeline, tasks often need to share information. One task discovers a value, and a later task uses it.
Your Context type (already defined as Record<string, string> in src/types.ts) acts as a shared key-value bag that flows through the pipeline. You'll create two tasks that demonstrate this: one that writes a value and one that reads it.
- In src/tasks.ts, add these two tasks below the checkDiskSpace task:
export const writeToContext: Task = {
name: "write-context",
description: "Write project name to context",
run: (ctx?: Context) => {
if (ctx) {
ctx.projectName = "task-runner";
}
return { status: "success", output: "Wrote project name to context" };
},
};
export const readFromContext: Task = {
name: "read-context",
description: "Read project name from context",
run: (ctx?: Context) => {
if (ctx && ctx.projectName) {
return { status: "success", output: `Project: ${ctx.projectName}` };
}
return { status: "failure", error: "No project name in context" };
},
};
What does this code do?
- writeToContext accepts the optional ctx parameter. If a context exists, it writes projectName into it. Later tasks in the same pipeline can read that value.
- readFromContext checks whether ctx.projectName exists. If it does, the task succeeds. If not, it returns a failure. This proves that data flows through the context from one task to the next.
- Save src/tasks.ts.
You won't see a change yet. The final substep wires these into the pipeline and runs everything.
Why a mutable context instead of return values?
Passing a shared object lets any task contribute data without changing the pipeline's return type. Each task still returns a TaskResult (success or failure), but side-channel data lives in the context.
This is a simple version of the pattern. Production pipelines often use immutable contexts or typed maps, but the principle is the same: a shared bag of values that grows as the pipeline progresses.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/tasks.ts before moving on.
ⓧ I'd like to double check the full code
import type { Task, Context } from "./types";
import { existsSync } from "node:fs";
export const checkBunVersion: Task = {
name: "check-bun-version",
description: "Verify Bun is running",
run: () => {
const version = Bun.version;
if (version) {
return { status: "success", output: `Bun version: ${version}` };
}
return { status: "failure", error: "Could not determine Bun version" };
},
};
export const checkFileExists: Task = {
name: "check-package-json",
description: "Check if package.json exists",
run: () => {
if (existsSync("package.json")) {
return { status: "success", output: "package.json found" };
}
return { status: "failure", error: "package.json not found" };
},
};
export const checkDiskSpace: Task = {
name: "check-disk-space",
description: "Simulate disk space check",
run: () => {
const freeGB = Math.floor(Math.random() * 100) + 10;
if (freeGB > 5) {
return { status: "success", output: `${freeGB}GB free disk space` };
}
return { status: "failure", error: `Only ${freeGB}GB free, need at least 5GB` };
},
};
export const writeToContext: Task = {
name: "write-context",
description: "Write project name to context",
run: (ctx?: Context) => {
if (ctx) {
ctx.projectName = "task-runner";
}
return { status: "success", output: "Wrote project name to context" };
},
};
export const readFromContext: Task = {
name: "read-context",
description: "Read project name from context",
run: (ctx?: Context) => {
if (ctx && ctx.projectName) {
return { status: "success", output: `Project: ${ctx.projectName}` };
}
return { status: "failure", error: "No project name in context" };
},
};
Wire everything together into the final CLI
You have a pipeline that stops on failure and tasks that communicate through context. The last piece is a formatted report that shows the learner (you!) exactly what happened: which tasks passed, which failed, and how long the run took.
- In src/runner.ts, add the printReport function below the runAll function:
export function printReport(tasks: Task[], results: TaskResult[]): void {
console.log(`\n${BOLD}--- Task Runner Report ---${RESET}\n`);
for (let i = 0; i < results.length; i++) {
const task = tasks[i];
const result = results[i];
const timestamp = new Date().toISOString();
if (isSuccess(result)) {
console.log(`${GREEN}[PASS]${RESET} ${task.name} (${timestamp})`);
console.log(` ${result.output}`);
} else if (isFailure(result)) {
console.log(`${RED}[FAIL]${RESET} ${task.name} (${timestamp})`);
console.log(` ${result.error}`);
}
}
const passed = results.filter(isSuccess).length;
const failed = results.filter(isFailure).length;
console.log(`\n${BOLD}Total: ${results.length} | Passed: ${passed} | Failed: ${failed}${RESET}\n`);
}
What does this code do?
- printReport loops through each result and prints the task name, an ISO timestamp, and either the output (green) or the error (red).
- It uses your isSuccess and isFailure type guards to safely access the correct field on each result variant.
- At the bottom, it prints summary totals so you can see at a glance how many tasks passed and failed.
- Save src/runner.ts.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/runner.ts before moving on to the final wiring.
ⓧ I'd like to double check the full code
import type { Task, TaskResult, Context } from "./types";
import { isSuccess, isFailure } from "./guards";
import { TaskRegistry } from "./registry";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
export function executeTask(task: Task, ctx?: Context): void {
const result = task.run(ctx);
switch (result.status) {
case "success":
console.log(`${GREEN}[PASS]${RESET} ${task.name}: ${result.output}`);
break;
case "failure":
console.log(`${RED}[FAIL]${RESET} ${task.name}: ${result.error}`);
break;
default: {
const _exhaustive: never = result;
throw new Error(`Unhandled status: ${JSON.stringify(_exhaustive)}`);
}
}
}
export function pipeline(tasks: Task[], ctx?: Context): TaskResult[] {
const results: TaskResult[] = [];
for (const task of tasks) {
const result = task.run(ctx);
results.push(result);
if (isFailure(result)) {
console.log(`${RED}[STOP]${RESET} Pipeline stopped at "${task.name}": ${result.error}`);
break;
}
}
return results;
}
export function runAll(registry: TaskRegistry<Task>): TaskResult[] {
const tasks = registry.getAll();
const results: TaskResult[] = [];
for (const task of tasks) {
const result = task.run();
results.push(result);
}
return results;
}
export function printReport(tasks: Task[], results: TaskResult[]): void {
console.log(`\n${BOLD}--- Task Runner Report ---${RESET}\n`);
for (let i = 0; i < results.length; i++) {
const task = tasks[i];
const result = results[i];
const timestamp = new Date().toISOString();
if (isSuccess(result)) {
console.log(`${GREEN}[PASS]${RESET} ${task.name} (${timestamp})`);
console.log(` ${result.output}`);
} else if (isFailure(result)) {
console.log(`${RED}[FAIL]${RESET} ${task.name} (${timestamp})`);
console.log(` ${result.error}`);
}
}
const passed = results.filter(isSuccess).length;
const failed = results.filter(isFailure).length;
console.log(`\n${BOLD}Total: ${results.length} | Passed: ${passed} | Failed: ${failed}${RESET}\n`);
}
Now for the final piece. You'll rewrite src/main.ts to import everything, register all 5 tasks, run the pipeline with a shared context, and print the report.
- Replace the contents of src/main.ts with the following:
import type { Context } from "./types";
import { TaskRegistry } from "./registry";
import { pipeline, printReport } from "./runner";
import {
checkBunVersion,
checkFileExists,
checkDiskSpace,
writeToContext,
readFromContext,
} from "./tasks";
// Create a registry and register all tasks
const registry = new TaskRegistry();
registry.register(checkBunVersion);
registry.register(checkFileExists);
registry.register(checkDiskSpace);
registry.register(writeToContext);
registry.register(readFromContext);
console.log("Registered tasks:", registry.list());
// Pull tasks from the registry, create a shared context, and run the pipeline
const tasks = registry.getAll();
const ctx: Context = {};
const results = pipeline(tasks, ctx);
// Print the formatted report
printReport(tasks, results);
What does this code do?
- It creates a TaskRegistry and registers all 5 tasks. The registry gives you a single place to manage which tasks exist.
- It creates an empty Context object and passes it to pipeline. As tasks run, writeToContext populates the context and readFromContext reads from it.
- Finally, printReport takes the tasks and results arrays and prints a color-coded summary to the terminal.
- Save src/main.ts.
- Run the full pipeline by executing this command in your terminal:
bun index.ts
You should see a list of registered task names, followed by the full task runner report. Each task shows green [PASS] or red [FAIL] output with a timestamp, and the bottom line shows totals like Total: 5 | Passed: 5 | Failed: 0.
Not seeing the report?
- If you see an import error, make sure you exported pipeline and printReport from src/runner.ts (both need the export keyword).
- If you see a [STOP] message and fewer than 5 results, that means the pipeline is working correctly. One of the earlier tasks failed and stopped execution. Check which task failed and verify its logic.
- Make sure writeToContext and readFromContext are both exported from src/tasks.ts with export const.
Help me debug my task runner pipeline output
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/main.ts and that the report printed successfully.
ⓧ I'd like to double check the full code
import type { Context } from "./types";
import { TaskRegistry } from "./registry";
import { pipeline, printReport } from "./runner";
import {
checkBunVersion,
checkFileExists,
checkDiskSpace,
writeToContext,
readFromContext,
} from "./tasks";
const registry = new TaskRegistry();
registry.register(checkBunVersion);
registry.register(checkFileExists);
registry.register(checkDiskSpace);
registry.register(writeToContext);
registry.register(readFromContext);
console.log("Registered tasks:", registry.list());
const tasks = registry.getAll();
const ctx: Context = {};
const results = pipeline(tasks, ctx);
printReport(tasks, results);
Secret mission
Add a Retry Mechanism with Generics
Real-world tasks fail. Networks drop, APIs timeout, disks hiccup. Your pipeline currently stops at the first failure. Can you build a generic retry wrapper that gives flaky tasks multiple chances to succeed?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files or delete them.
Resources you used:
- The task-runner project folder on your Desktop (local files).
Keep everything running
No action needed. Choose this if you want to keep experimenting with your task runner or extending it further.
- Your task-runner folder stays on your Desktop, ready to use anytime with bun index.ts.
Pause - I'll come back to this later
Since this is a local project with no running processes, there is nothing to pause. Your files sit on disk and use minimal storage.
- Your project is already paused. Come back anytime and run bun index.ts from the task-runner folder to pick up where you left off.
Delete - I don't want to use this again
Remove all project files from your machine.
- Delete the task-runner folder by running this command:
- Confirm the folder is gone by running:
rm -rf ~/Desktop/task-runner
- Confirm the folder is gone by running:
ls ~/Desktop/task-runner
You should see a "No such file or directory" message.
Nice Work!
Nice Work!
Nice work! You just built a fully typed command-line task runner in TypeScript that defines, executes, and reports on tasks with compile-time safety.
You've learned how to:
- Define interfaces and discriminated unions to model task shapes and success/failure states, forcing TypeScript to catch invalid configurations before they ever run.
- Write type guard functions and use exhaustiveness checking with never to safely narrow unknown data and guarantee every possible outcome is handled.
- Build a generic task registry and a pipeline function that chains typed tasks together, passing context between them and printing a formatted report with colored terminal output.
- Secret Mission: Added a generic retry mechanism using withRetry that wraps any task with configurable attempts and delay.
Ready to quiz yourself?