Build a Type-Safe Workflow Engine

Use TypeScript's type system to make invalid state transitions compile-time errors.

Introduction

30 Second Summary

Every project management tool enforces rules about what can happen next. A task can't be marked "done" if nobody started it. These rules usually live as runtime checks that only fail when someone triggers the bug in production.

In this project, you will build a CLI workflow engine in TypeScript where the type system itself prevents invalid state transitions at compile time. No runtime surprises.

What You'll Build

A command-line task workflow engine that manages tasks through five states (Draft, Active, InReview, Completed, and Cancelled) with TypeScript enforcing valid transitions at the type level.

By the end of this project, you'll have:

  • A discriminated union modelling five task states, where each state carries only the data relevant to that state.
  • A generic transition function with constrained generics that makes invalid state changes a compile-time error.
  • CLI commands to create, start, review, complete, cancel, list, and show tasks with JSON file persistence.
  • Secret Mission: Make the engine truly generic by adding a second entity type (Bug Reports) that reuses the same state machine infrastructure.

Are there any prerequisites?

This project assumes you've completed a beginner TypeScript project covering interfaces, basic types, and functions. You should already have Node.js and a code editor installed.

Set Up the Project

Every TypeScript project needs a foundation: a compiler that converts your code into JavaScript, and a configuration file that tells the compiler how strict to be.

In this step, you'll create a project folder, install TypeScript 6.0, configure the compiler, and verify the entire toolchain works end to end.

In this step, get ready to:
  • Set up a Node.js project with TypeScript 6.0 and strict type checking.
  • Configure the TypeScript compiler for a Node.js CLI app.
  • Verify the build and run scripts compile and execute your code.
Create the project and install TypeScript
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir workflow-engine
cd workflow-engine
  • Confirm the folder exists by running:
ls

You should see the workflow-engine folder listed in the output.

  • Initialize a new Node.js project by running:
npm init -y

This creates a package.json file that tracks your project's dependencies and scripts.

  • Install TypeScript and the Node.js type declarations as dev dependencies by running:
npm install typescript @types/node --save-dev

What does this command do?

  • typescript installs the TypeScript compiler that converts your .ts files into JavaScript.
  • @types/node provides type declarations for Node.js built-in modules like fs and crypto.
  • --save-dev marks them as development-only dependencies, since they're only needed during compilation.

Seeing permission errors or network failures?

Make sure you're connected to the internet. If you see EACCES permission errors on macOS, try prefixing the command with sudo. On Windows, try running Command Prompt as Administrator.

Help me fix my npm install error

  • Generate a TypeScript configuration file by running:
npx tsc --init

You should see a message confirming that a new tsconfig.json was created.

Why does TypeScript 6.0 matter here?

TypeScript 6.0 enables strict mode by default. This means the compiler catches more bugs automatically without extra configuration.

In older versions, you had to manually enable strict mode. With 6.0, it's the default starting point for every new project.

Configure the TypeScript compiler

The generated tsconfig.json contains many commented-out options. You'll replace it with a clean configuration tailored for this Node.js CLI project.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the workflow-engine folder on your Desktop, then click Open.
  • In the Explorer sidebar on the left, click tsconfig.json to open it.
  • Select all the existing content (Cmd+A on macOS or Ctrl+A on Windows) and delete it.
  • Replace it with the following configuration:
{
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2025",
    "strict": true,
    "types": ["node"]
  },
  "include": ["src/**/*"]
}

What does each option do?

  • outDir sends compiled JavaScript files to a dist/ folder, keeping source and output separate.
  • rootDir tells TypeScript that all source files live in src/.
  • module and moduleResolution set to nodenext enables modern ES module imports that work in Node.js.
  • strict enables the full suite of type-checking rules that catch bugs at compile time.
  • types set to ["node"] tells TypeScript to include the Node.js type declarations you installed earlier.
  • Save tsconfig.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Create the src/ directory. Switch back to your terminal and run:
mkdir src
  • Confirm the folder was created by running:
ls

You should see node_modules, package.json, package-lock.json, tsconfig.json, and src in the output.

✔️ Awesome, I've got everything!

Great. Make sure you've saved tsconfig.json before moving on.

ⓧ I'd like to double check the full code

Here's exactly what your tsconfig.json should look like:

{
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2025",
    "strict": true,
    "types": ["node"]
  },
  "include": ["src/**/*"]
}
Add build scripts and verify the toolchain

Your package.json needs two things: a "type": "module" field so Node.js treats your files as ES modules, and build scripts to compile and run your code.

  • In VS Code, click package.json in the Explorer sidebar to open it.
  • Select all the existing content (Cmd+A on macOS or Ctrl+A on Windows) and replace it with the following:
{
  "name": "workflow-engine",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "npx tsc",
    "start": "node dist/index.js"
  },
  "devDependencies": {
    "typescript": "~6.0.0",
    "@types/node": "^22.0.0"
  }
}

What do these scripts do?

  • "build": "npx tsc" runs the TypeScript compiler, which reads tsconfig.json and compiles everything in src/ to dist/.
  • "start": "node dist/index.js" runs the compiled JavaScript entry point.
  • "type": "module" tells Node.js to use ES module imports (import/export) instead of the older require syntax.
  • Save package.json.

Now create a simple entry point to test the toolchain.

  • In the Explorer sidebar, right-click the src folder and select New File.
  • Name the file index.ts and press Enter.
  • Add the following code to src/index.ts:
console.log("Hello from workflow engine");
  • Save src/index.ts.

Time to verify that the full build pipeline works.

  • Switch back to your terminal and compile the project by running:
npm run build

You should see no errors. TypeScript compiles src/index.ts into dist/index.js.

  • Run the compiled code by running:
npm run start

You should see Hello from workflow engine printed in your terminal.

Seeing a compile error or no output?

  • If you see a "Cannot find module" error, make sure tsconfig.json has "rootDir": "./src" and that index.ts is inside the src/ folder.
  • If npm run start says the file is not found, make sure npm run build completed without errors first. Check that dist/index.js exists.
  • If you see ERR_MODULE_NOT_FOUND, confirm that "type": "module" is in your package.json.

Help me fix my TypeScript build error

✔️ Awesome, I've got everything!

Great. Double check you've saved both package.json and src/index.ts.

ⓧ I'd like to double check the full code

Here's exactly what your package.json should look like:

{
  "name": "workflow-engine",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "npx tsc",
    "start": "node dist/index.js"
  },
  "devDependencies": {
    "typescript": "~6.0.0",
    "@types/node": "^22.0.0"
  }
}

And here's your src/index.ts:

console.log("Hello from workflow engine");

Your toolchain is locked in. TypeScript compiles your code, Node.js runs it, and strict mode is watching for mistakes. Next up, you'll define the type system that makes invalid state transitions impossible.

Define Your State Types

Your toolchain is ready. TypeScript compiles, Node runs the output, and the strict flag is already enforcing type safety.

Now it's time to model the problem. A task in your workflow engine can be in one of five states: Draft, Active, InReview, Completed, or Cancelled. Each state carries different data. For example, only a Completed task has a completedAt timestamp and a duration. The type system needs to know this so it can prevent you from accessing data that doesn't exist yet.

In this step, get ready to:
  • Define five state interfaces with a shared discriminant field.
  • Create a union type and an exhaustiveness helper.
  • Encode valid state transitions as a type-level constraint.
Create the state interfaces

A discriminated union is a pattern where every member of a union shares a common property (the discriminant) with a unique literal value. TypeScript uses this property to narrow the type and know exactly which fields are available.

In your workflow engine, the discriminant is a status field. Each interface has a different literal string for status, plus any data that only exists in that state.

  • Create a new file called src/types.ts in your src/ directory.
  • Add the first two state interfaces by pasting this code:
// === Task States (Discriminated Union) ===

export interface DraftTask {
  id: string;
  status: "draft";
  title: string;
  createdAt: string;
}

export interface ActiveTask {
  id: string;
  status: "active";
  title: string;
  createdAt: string;
  startedAt: string;
}

What does this code do?

Each interface declares exactly what data exists when a task is in that state.

  • status: "draft" is a literal type. It can only ever be the string "draft". This is the discriminant that TypeScript uses to tell interfaces apart.
  • DraftTask has the bare minimum: an ID, a title, and a creation timestamp.
  • ActiveTask adds startedAt because that data only exists once someone starts working on the task.

You won't see a change yet. The next chunk adds the remaining three state interfaces.

  • Add the remaining three state interfaces below ActiveTask in src/types.ts:
export interface InReviewTask {
  id: string;
  status: "in-review";
  title: string;
  createdAt: string;
  startedAt: string;
  reviewRequestedAt: string;
}

export interface CompletedTask {
  id: string;
  status: "completed";
  title: string;
  createdAt: string;
  startedAt: string;
  completedAt: string;
  duration: number;
}

export interface CancelledTask {
  id: string;
  status: "cancelled";
  title: string;
  createdAt: string;
  cancelledAt: string;
  reason: string;
}

What does this code do?

  • InReviewTask keeps startedAt (the task was active before it went to review) and adds reviewRequestedAt.
  • CompletedTask adds completedAt and duration (a number representing seconds). This data only makes sense for a finished task.
  • CancelledTask drops startedAt (a task can be cancelled from Draft before it ever starts) and adds a reason string explaining why.
  • Save src/types.ts.
  • Run the compiler to confirm your interfaces have no syntax errors:
npm run build
  • You should see no output and no errors. The command exits silently on success.

Seeing a compile error?

  • Check that every interface has matching opening and closing braces.
  • Make sure each status value is in double quotes (a literal string type like "draft", not a bare word).
  • Confirm the file is saved inside the src/ folder. The tsconfig.json only compiles files under src/.

Why is my TypeScript file not compiling?

Define the Task union and exhaustiveness helper

Five separate interfaces are useful, but you need a single type that means "any task, regardless of state." A union type combines all five into one. You also need a helper function that guarantees you handle every possible state in a switch statement.

  • Add the following code below your CancelledTask interface in src/types.ts:
export type Task = DraftTask | ActiveTask | InReviewTask | CompletedTask | CancelledTask;

export type TaskStatus = Task["status"];

// === Exhaustiveness Helper ===

export function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}

What does this code do?

  • Task is the union of all five interfaces. Any variable typed as Task could be in any of the five states.
  • TaskStatus uses an indexed access type (Task["status"]) to extract just the status strings: "draft" | "active" | "in-review" | "completed" | "cancelled".
  • assertNever accepts a value of type never. If you use it in a switch statement's default case, TypeScript will error at compile time if any union member is unhandled. This is exhaustiveness checking.

Why does assertNever work?

In a switch that covers all five statuses, the default branch is unreachable. TypeScript narrows the value to never because no possible type remains. If you forget a case, the unhandled type is NOT never, so the assignment fails and you get a compile error. The compiler catches the missing case for you.

  • Save src/types.ts and run the compiler:
npm run build
  • The command should exit silently with no errors.

Seeing a type error on TaskStatus?

  • Make sure the square brackets in Task["status"] use quotes around status. This is an indexed access type, not an array.
  • Check that all five interfaces have the export keyword in front of them.

Help me fix my TypeScript union type.

Define the transition rules

Your interfaces describe what data each state holds. Now you need to describe which state transitions are allowed. A Draft can become Active or Cancelled, but it cannot jump straight to Completed.

You will encode these rules as a const object. The as const assertion preserves the literal tuple types so TypeScript can enforce valid transitions at compile time, not just at runtime.

  • Add the transition rules below the assertNever function in src/types.ts:
// === Transition Rules ===

export const TASK_TRANSITIONS = {
  "draft": ["active", "cancelled"],
  "active": ["in-review", "cancelled"],
  "in-review": ["completed", "active"],
  "completed": [],
  "cancelled": [],
} as const;

export type TaskTransitions = typeof TASK_TRANSITIONS;

// === Valid Transition Type ===

export type ValidTransition<From extends TaskStatus> = TaskTransitions[From][number];

What does this code do?

  • TASK_TRANSITIONS maps each status to the statuses it can transition to. "completed" and "cancelled" have empty arrays because they are terminal states.
  • as const tells TypeScript to treat the arrays as readonly tuples of literal strings (e.g., readonly ["active", "cancelled"]) instead of widening them to string[]. Without this, the type system loses the information it needs to enforce transitions.
  • ValidTransition<From> is a generic type that looks up the tuple for a given status and extracts its members using [number] (indexed access). For example, ValidTransition<"draft"> resolves to "active" | "cancelled".

Why as const instead of a plain object?

Without as const, TypeScript infers the type of TASK_TRANSITIONS.draft as string[]. That means any string would be a "valid" transition, defeating the purpose of type checking. With as const, the type is the precise tuple readonly ["active", "cancelled"], and TypeScript can reject any value not in that tuple.

  • Save src/types.ts and run the compiler one more time:
npm run build
  • The command should exit silently. Your entire type system is now in place.

Getting an error about TASK_TRANSITIONS?

  • Make sure as const appears after the closing brace of the object, before the semicolon.
  • Check that the keys inside TASK_TRANSITIONS exactly match the status values in your interfaces ("draft", "active", "in-review", "completed", "cancelled").
  • Confirm that TaskStatus is defined above TASK_TRANSITIONS in the file. The ValidTransition type depends on it.

Help me fix my transition rules type.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/types.ts file for comparison.

// === Task States (Discriminated Union) ===

export interface DraftTask {
  id: string;
  status: "draft";
  title: string;
  createdAt: string;
}

export interface ActiveTask {
  id: string;
  status: "active";
  title: string;
  createdAt: string;
  startedAt: string;
}

export interface InReviewTask {
  id: string;
  status: "in-review";
  title: string;
  createdAt: string;
  startedAt: string;
  reviewRequestedAt: string;
}

export interface CompletedTask {
  id: string;
  status: "completed";
  title: string;
  createdAt: string;
  startedAt: string;
  completedAt: string;
  duration: number;
}

export interface CancelledTask {
  id: string;
  status: "cancelled";
  title: string;
  createdAt: string;
  cancelledAt: string;
  reason: string;
}

export type Task = DraftTask | ActiveTask | InReviewTask | CompletedTask | CancelledTask;

export type TaskStatus = Task["status"];

// === Transition Rules ===

export const TASK_TRANSITIONS = {
  "draft": ["active", "cancelled"],
  "active": ["in-review", "cancelled"],
  "in-review": ["completed", "active"],
  "completed": [],
  "cancelled": [],
} as const;

export type TaskTransitions = typeof TASK_TRANSITIONS;

// === Valid Transition Type ===

export type ValidTransition<From extends TaskStatus> = TaskTransitions[From][number];

// === Exhaustiveness Helper ===

export function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}

Your type system now knows every possible task state, what data each state carries, and which transitions are allowed. Next up, you'll build the state machine function that uses these types to enforce transitions at compile time.

Build the Generic State Machine

You have a complete type system describing your task states and the rules governing valid transitions. Now you need a function that actually performs those transitions safely.

The goal of this step is to write a single transitionTask function that TypeScript itself polices. If someone tries to skip a state (like jumping from Draft straight to Completed), the code won't just fail at runtime. It won't even compile.

In this step, get ready to:
  • Write a generic transition function with constrained type parameters.
  • Implement type-narrowed switch cases that build state-specific data.
  • Verify that invalid transitions produce compile-time errors.
Write the generic transition function

The heart of a type-safe state machine is a function whose signature encodes the rules. Instead of checking transitions with if statements at runtime, you declare the constraints in the type parameters so the compiler rejects illegal calls before the code ever runs.

  • Create a new file src/machine.ts and add the following code:
import {
  Task,
  TaskStatus,
  ValidTransition,
  DraftTask,
  ActiveTask,
  InReviewTask,
  CompletedTask,
  CancelledTask,
  assertNever,
} from "./types.js";

export function transitionTask<
  From extends TaskStatus,
  To extends ValidTransition<From>
>(
  task: Extract<Task, { status: From }>,
  to: To,
  extra?: { reason?: string }
): Extract<Task, { status: To }> {
  const now = new Date().toISOString();

  switch (to) {
    case "active": {
      const source = task as DraftTask;
      return {
        id: source.id,
        status: "active",
        title: source.title,
        createdAt: source.createdAt,
        startedAt: now,
      } as Extract<Task, { status: To }>;
    }
    case "in-review": {
      const source = task as ActiveTask;
      return {
        id: source.id,
        status: "in-review",
        title: source.title,
        createdAt: source.createdAt,
        startedAt: source.startedAt,
        reviewRequestedAt: now,
      } as Extract<Task, { status: To }>;
    }
    case "completed": {
      const source = task as InReviewTask;
      const startedAt = new Date(source.startedAt).getTime();
      const completedAt = new Date(now).getTime();
      const duration = Math.round((completedAt - startedAt) / 1000);
      return {
        id: source.id,
        status: "completed",
        title: source.title,
        createdAt: source.createdAt,
        startedAt: source.startedAt,
        completedAt: now,
        duration,
      } as Extract<Task, { status: To }>;
    }
    case "cancelled": {
      return {
        id: task.id,
        status: "cancelled",
        title: task.title,
        createdAt: task.createdAt,
        cancelledAt: now,
        reason: extra?.reason ?? "No reason provided",
      } as Extract<Task, { status: To }>;
    }
    default:
      return assertNever(to as never);
  }
}

What does this function signature mean?

The generic parameters are doing the heavy lifting here. From extends TaskStatus constrains the first type parameter to one of the five valid status strings.

To extends ValidTransition<From> is the key constraint. It says the target status MUST be one of the transitions allowed from the current status. If From is "draft", then To can only be "active" or "cancelled".

Extract<Task, { status: From }> pulls the specific union member matching that status. So if From is "draft", the input type becomes DraftTask specifically. The return type uses the same pattern for the target state.

  • Save src/machine.ts.
  • Run the compiler to check for errors by running:
npm run build

You should see no errors. The compiler accepts the file cleanly.

Seeing a compilation error?

  • Check that the import path ends in .js (not .ts). The nodenext module resolution requires .js extensions in import paths even for TypeScript source files.
  • Make sure every import name matches the export in src/types.ts exactly, including capitalization.
  • Confirm your tsconfig.json has "types": ["node"] set.

Help me debug my TypeScript compilation error in machine.ts

Understand the type narrowing in each case

The switch statement inside transitionTask switches on the target status (to), not the current status. Each case constructs the appropriate state-specific data for the destination state.

How does type narrowing work in each case?

  • The "active" case casts the source to DraftTask because only draft tasks can transition to active. It adds startedAt (new timestamp) while carrying forward the original fields.
  • The "in-review" case casts to ActiveTask because only active tasks can move to review. It preserves startedAt and adds reviewRequestedAt.
  • The "completed" case casts to InReviewTask and calculates the duration in seconds between startedAt and now.
  • The "cancelled" case works on any cancellable task (draft or active). It adds cancelledAt and a reason from the optional extra parameter.
  • The default case calls assertNever. If you ever add a new transition target without handling it, TypeScript will error here because the unhandled case is not assignable to never.

Why switch on the target state instead of the current state?

The generic constraint already guarantees that the current state is valid for the requested transition. Switching on to lets you focus on constructing the correct output shape for the destination state. Each case knows exactly which fields the target interface requires.

Test compile-time safety

The entire point of this architecture is that invalid transitions fail at compile time. You can prove this by writing code that deliberately breaks the rules and watching the compiler reject it.

  • Open src/index.ts and replace its contents with this temporary test code:
import { DraftTask } from "./types.js";
import { transitionTask } from "./machine.js";

// Create a fake draft task for testing
const draft: DraftTask = {
  id: "test-123",
  status: "draft",
  title: "Test Task",
  createdAt: new Date().toISOString(),
};

// INVALID: Draft cannot go directly to Completed
const bad = transitionTask(draft, "completed");

What does this test prove?

A draft task can only transition to "active" or "cancelled" according to your TASK_TRANSITIONS rules. Passing "completed" violates the To extends ValidTransition<From> constraint. TypeScript should refuse to compile this.

  • Save src/index.ts and attempt to compile by running:
npm run build

You should see a type error. The compiler will report that the argument is not assignable to the constrained parameter type. This is exactly what you want.

What should I see?

The error message will indicate that "completed" is not assignable to the valid transition type for a draft task. The valid options from "draft" are only "active" and "cancelled". This proves your generic constraints are working.

  • Now restore src/index.ts to its original content:
console.log("Hello from workflow engine");
  • Save src/index.ts and run the compiler again to confirm everything compiles cleanly:
npm run build

You should see no errors. Both files compile successfully.

Still seeing errors after restoring index.ts?

  • Make sure you removed ALL of the test code from src/index.ts. The file should contain only the single console.log line.
  • Check that you saved both src/index.ts and src/machine.ts before running the build.

Help me fix my TypeScript build errors after removing test code

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/machine.ts file:

import {
  Task,
  TaskStatus,
  ValidTransition,
  DraftTask,
  ActiveTask,
  InReviewTask,
  CompletedTask,
  CancelledTask,
  assertNever,
} from "./types.js";

export function transitionTask<
  From extends TaskStatus,
  To extends ValidTransition<From>
>(
  task: Extract<Task, { status: From }>,
  to: To,
  extra?: { reason?: string }
): Extract<Task, { status: To }> {
  const now = new Date().toISOString();

  switch (to) {
    case "active": {
      const source = task as DraftTask;
      return {
        id: source.id,
        status: "active",
        title: source.title,
        createdAt: source.createdAt,
        startedAt: now,
      } as Extract<Task, { status: To }>;
    }
    case "in-review": {
      const source = task as ActiveTask;
      return {
        id: source.id,
        status: "in-review",
        title: source.title,
        createdAt: source.createdAt,
        startedAt: source.startedAt,
        reviewRequestedAt: now,
      } as Extract<Task, { status: To }>;
    }
    case "completed": {
      const source = task as InReviewTask;
      const startedAt = new Date(source.startedAt).getTime();
      const completedAt = new Date(now).getTime();
      const duration = Math.round((completedAt - startedAt) / 1000);
      return {
        id: source.id,
        status: "completed",
        title: source.title,
        createdAt: source.createdAt,
        startedAt: source.startedAt,
        completedAt: now,
        duration,
      } as Extract<Task, { status: To }>;
    }
    case "cancelled": {
      return {
        id: task.id,
        status: "cancelled",
        title: task.title,
        createdAt: task.createdAt,
        cancelledAt: now,
        reason: extra?.reason ?? "No reason provided",
      } as Extract<Task, { status: To }>;
    }
    default:
      return assertNever(to as never);
  }
}

And src/index.ts should be back to:

console.log("Hello from workflow engine");

Your state machine now enforces valid transitions at compile time. Next up, you'll wire this into a working CLI with file persistence so you can actually create and transition tasks from the terminal.

Wire Up CLI Commands and Persistence

Your type-safe transition function can move tasks between states, but right now there's no way to actually use it. The engine has no memory (persistence) and no interface (CLI commands).

In this step, you'll build the three remaining layers: a JSON file store that saves tasks to disk, command functions that validate and execute transitions, and a CLI entry point that routes terminal arguments to the right command.

In this step, get ready to:
  • Create a persistence layer that loads and saves tasks as JSON with a type guard for safety.
  • Implement CLI commands that call your transition function with proper status guards.
  • Wire up an entry point that parses terminal arguments and routes to the correct command.
Create the persistence layer

Tasks need to survive between CLI invocations. Every time you run the engine, it starts a fresh Node.js process, so without a file on disk, all tasks would vanish.

  • Create a new file src/store.ts and add the following code:
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { Task, TaskStatus } from "./types.js";

const DATA_FILE = "./tasks.json";

const VALID_STATUSES: TaskStatus[] = ["draft", "active", "in-review", "completed", "cancelled"];

function isTask(obj: unknown): obj is Task {
  if (typeof obj !== "object" || obj === null) return false;
  const record = obj as Record<string, unknown>;
  return (
    typeof record.id === "string" &&
    typeof record.status === "string" &&
    VALID_STATUSES.includes(record.status as TaskStatus) &&
    typeof record.title === "string" &&
    typeof record.createdAt === "string"
  );
}

export function loadTasks(): Task[] {
  if (!existsSync(DATA_FILE)) return [];
  const raw = readFileSync(DATA_FILE, "utf-8");
  const parsed: unknown = JSON.parse(raw);
  if (!Array.isArray(parsed)) return [];
  return parsed.filter(isTask);
}

export function saveTasks(tasks: Task[]): void {
  writeFileSync(DATA_FILE, JSON.stringify(tasks, null, 2), "utf-8");
}

export function findTask(tasks: Task[], id: string): Task | undefined {
  return tasks.find((t) => t.id === id);
}

What does this code do?

The isTask function is a type guard. JSON files store raw data with no type information, so when you load from disk, TypeScript sees unknown. The type guard checks that each object has the right shape before TypeScript narrows it to Task.

The loadTasks function reads the JSON file from disk and filters every entry through isTask. Any corrupted or invalid entries are silently dropped, keeping the rest of your app type-safe.

  • Save src/store.ts.

You won't see a change yet. The next files will use this store.

Seeing a red underline on the imports?

Make sure your tsconfig.json includes "types": ["node"] in compilerOptions. TypeScript 6.0 defaults types to an empty array, so Node.js built-in modules like node:fs won't resolve without it.

help me fix TypeScript import errors for node:fs

✔️ Awesome, I've got everything!

Great. Make sure you've saved src/store.ts before moving on.

ⓧ I'd like to double check the full code

import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { Task, TaskStatus } from "./types.js";

const DATA_FILE = "./tasks.json";

const VALID_STATUSES: TaskStatus[] = ["draft", "active", "in-review", "completed", "cancelled"];

function isTask(obj: unknown): obj is Task {
  if (typeof obj !== "object" || obj === null) return false;
  const record = obj as Record<string, unknown>;
  return (
    typeof record.id === "string" &&
    typeof record.status === "string" &&
    VALID_STATUSES.includes(record.status as TaskStatus) &&
    typeof record.title === "string" &&
    typeof record.createdAt === "string"
  );
}

export function loadTasks(): Task[] {
  if (!existsSync(DATA_FILE)) return [];
  const raw = readFileSync(DATA_FILE, "utf-8");
  const parsed: unknown = JSON.parse(raw);
  if (!Array.isArray(parsed)) return [];
  return parsed.filter(isTask);
}

export function saveTasks(tasks: Task[]): void {
  writeFileSync(DATA_FILE, JSON.stringify(tasks, null, 2), "utf-8");
}

export function findTask(tasks: Task[], id: string): Task | undefined {
  return tasks.find((t) => t.id === id);
}
Implement the CLI commands

Each command follows the same pattern: load all tasks from disk, find the target task, check that it's in the right state, call transitionTask, and save the result. The status check narrows the type so TypeScript knows the transition is valid.

  • Create a new file src/commands.ts and start with the imports and the createTask function:
import { randomUUID } from "node:crypto";
import { DraftTask, Task, TaskStatus } from "./types.js";
import { transitionTask } from "./machine.js";
import { loadTasks, saveTasks, findTask } from "./store.js";
import { formatTask, showTaskDetails } from "./display.js";

export function createTask(title: string): void {
  const tasks = loadTasks();
  // Build a new DraftTask with a unique ID
  const newTask: DraftTask = {
    id: randomUUID(),
    status: "draft",
    title,
    createdAt: new Date().toISOString(),
  };
  tasks.push(newTask);
  saveTasks(tasks);
  console.log(`Created task: ${newTask.id.slice(0, 8)} - "${title}"`);
}

What does this code do?

The createTask function is the only command that doesn't use transitionTask. It builds a brand new DraftTask directly, since "draft" is always the starting state. Typing the variable as DraftTask ensures you include all required fields for that state.

The randomUUID() call from node:crypto generates a unique identifier. The output slices it to the first 8 characters for readability.

  • Below createTask, add the transition commands that move tasks through the workflow:
export function startTask(id: string): void {
  const tasks = loadTasks();
  const task = findTask(tasks, id);
  if (!task) {
    console.error(`Task not found: ${id}`);
    return;
  }
  // Status check narrows the type so transitionTask accepts it
  if (task.status !== "draft") {
    console.error(`Cannot start task in status "${task.status}". Must be "draft".`);
    return;
  }
  const updated = transitionTask(task, "active");
  const index = tasks.findIndex((t) => t.id === id);
  tasks[index] = updated;
  saveTasks(tasks);
  console.log(`Started task: ${updated.id.slice(0, 8)}`);
}

export function reviewTask(id: string): void {
  const tasks = loadTasks();
  const task = findTask(tasks, id);
  if (!task) {
    console.error(`Task not found: ${id}`);
    return;
  }
  if (task.status !== "active") {
    console.error(`Cannot send to review from status "${task.status}". Must be "active".`);
    return;
  }
  const updated = transitionTask(task, "in-review");
  const index = tasks.findIndex((t) => t.id === id);
  tasks[index] = updated;
  saveTasks(tasks);
  console.log(`Sent to review: ${updated.id.slice(0, 8)}`);
}

export function completeTask(id: string): void {
  const tasks = loadTasks();
  const task = findTask(tasks, id);
  if (!task) {
    console.error(`Task not found: ${id}`);
    return;
  }
  if (task.status !== "in-review") {
    console.error(`Cannot complete task in status "${task.status}". Must be "in-review".`);
    return;
  }
  const updated = transitionTask(task, "completed");
  const index = tasks.findIndex((t) => t.id === id);
  tasks[index] = updated;
  saveTasks(tasks);
  console.log(`Completed task: ${updated.id.slice(0, 8)} (took ${updated.duration}s)`);
}

export function cancelTask(id: string, reason: string): void {
  const tasks = loadTasks();
  const task = findTask(tasks, id);
  if (!task) {
    console.error(`Task not found: ${id}`);
    return;
  }
  // Cancel is valid from draft OR active
  if (task.status !== "draft" && task.status !== "active") {
    console.error(`Cannot cancel task in status "${task.status}". Must be "draft" or "active".`);
    return;
  }
  const updated = transitionTask(task, "cancelled", { reason });
  const index = tasks.findIndex((t) => t.id === id);
  tasks[index] = updated;
  saveTasks(tasks);
  console.log(`Cancelled task: ${updated.id.slice(0, 8)} (reason: ${reason})`);
}

Why the status check before transitionTask?

Each if (task.status !== "draft") check does double duty. At runtime, it gives a helpful error message. At compile time, it narrows the type from Task (the union of all 5 states) to the specific state interface that transitionTask requires.

Without the narrowing check, TypeScript would refuse to compile because it can't prove the transition is valid for every possible state in the union.

  • Below cancelTask, add the read-only commands that list and show tasks:
export function listTasks(filterStatus?: string): void {
  const tasks = loadTasks();
  const filtered = filterStatus
    ? tasks.filter((t) => t.status === filterStatus)
    : tasks;

  if (filtered.length === 0) {
    console.log("No tasks found.");
    return;
  }

  for (const task of filtered) {
    console.log(formatTask(task));
  }
}

export function showTask(id: string): void {
  const tasks = loadTasks();
  const task = findTask(tasks, id);
  if (!task) {
    console.error(`Task not found: ${id}`);
    return;
  }
  console.log(showTaskDetails(task));
}

What does this code do?

The listTasks function takes an optional status filter. If provided, it shows only tasks in that state. Otherwise it shows everything.

The showTask function displays a single task's full details using the showTaskDetails formatter you'll create next.

  • Save src/commands.ts.

You won't see a change yet. The file imports from display.ts which doesn't exist, so it won't compile until the next chunk.

✔️ Awesome, I've got everything!

Great. Make sure you've saved src/commands.ts before moving on.

ⓧ I'd like to double check the full code

import { randomUUID } from "node:crypto";
import { DraftTask, Task, TaskStatus } from "./types.js";
import { transitionTask } from "./machine.js";
import { loadTasks, saveTasks, findTask } from "./store.js";
import { formatTask, showTaskDetails } from "./display.js";

export function createTask(title: string): void {
  const tasks = loadTasks();
  const newTask: DraftTask = {
    id: randomUUID(),
    status: "draft",
    title,
    createdAt: new Date().toISOString(),
  };
  tasks.push(newTask);
  saveTasks(tasks);
  console.log(`Created task: ${newTask.id.slice(0, 8)} - "${title}"`);
}

export function startTask(id: string): void {
  const tasks = loadTasks();
  const task = findTask(tasks, id);
  if (!task) {
    console.error(`Task not found: ${id}`);
    return;
  }
  if (task.status !== "draft") {
    console.error(`Cannot start task in status "${task.status}". Must be "draft".`);
    return;
  }
  const updated = transitionTask(task, "active");
  const index = tasks.findIndex((t) => t.id === id);
  tasks[index] = updated;
  saveTasks(tasks);
  console.log(`Started task: ${updated.id.slice(0, 8)}`);
}

export function reviewTask(id: string): void {
  const tasks = loadTasks();
  const task = findTask(tasks, id);
  if (!task) {
    console.error(`Task not found: ${id}`);
    return;
  }
  if (task.status !== "active") {
    console.error(`Cannot send to review from status "${task.status}". Must be "active".`);
    return;
  }
  const updated = transitionTask(task, "in-review");
  const index = tasks.findIndex((t) => t.id === id);
  tasks[index] = updated;
  saveTasks(tasks);
  console.log(`Sent to review: ${updated.id.slice(0, 8)}`);
}

export function completeTask(id: string): void {
  const tasks = loadTasks();
  const task = findTask(tasks, id);
  if (!task) {
    console.error(`Task not found: ${id}`);
    return;
  }
  if (task.status !== "in-review") {
    console.error(`Cannot complete task in status "${task.status}". Must be "in-review".`);
    return;
  }
  const updated = transitionTask(task, "completed");
  const index = tasks.findIndex((t) => t.id === id);
  tasks[index] = updated;
  saveTasks(tasks);
  console.log(`Completed task: ${updated.id.slice(0, 8)} (took ${updated.duration}s)`);
}

export function cancelTask(id: string, reason: string): void {
  const tasks = loadTasks();
  const task = findTask(tasks, id);
  if (!task) {
    console.error(`Task not found: ${id}`);
    return;
  }
  if (task.status !== "draft" && task.status !== "active") {
    console.error(`Cannot cancel task in status "${task.status}". Must be "draft" or "active".`);
    return;
  }
  const updated = transitionTask(task, "cancelled", { reason });
  const index = tasks.findIndex((t) => t.id === id);
  tasks[index] = updated;
  saveTasks(tasks);
  console.log(`Cancelled task: ${updated.id.slice(0, 8)} (reason: ${reason})`);
}

export function listTasks(filterStatus?: string): void {
  const tasks = loadTasks();
  const filtered = filterStatus
    ? tasks.filter((t) => t.status === filterStatus)
    : tasks;

  if (filtered.length === 0) {
    console.log("No tasks found.");
    return;
  }

  for (const task of filtered) {
    console.log(formatTask(task));
  }
}

export function showTask(id: string): void {
  const tasks = loadTasks();
  const task = findTask(tasks, id);
  if (!task) {
    console.error(`Task not found: ${id}`);
    return;
  }
  console.log(showTaskDetails(task));
}
Create the display module and wire up the CLI

The display module formats tasks for terminal output. It uses the same exhaustiveness-checked switch pattern you saw in the transition function, ensuring every state gets a display format.

  • Create a new file src/display.ts and add the following code:
import { Task, assertNever } from "./types.js";

export function formatTask(task: Task): string {
  // Build a base summary line with status badge and title
  const base = `[${task.status.toUpperCase()}] ${task.id.slice(0, 8)} - ${task.title}`;

  // Switch on the discriminant to show state-specific details
  switch (task.status) {
    case "draft":
      return `${base} (created: ${task.createdAt})`;
    case "active":
      return `${base} (started: ${task.startedAt})`;
    case "in-review":
      return `${base} (review requested: ${task.reviewRequestedAt})`;
    case "completed":
      return `${base} (completed: ${task.completedAt}, took ${task.duration}s)`;
    case "cancelled":
      return `${base} (cancelled: ${task.cancelledAt}, reason: ${task.reason})`;
    default:
      return assertNever(task);
  }
}

export function showTaskDetails(task: Task): string {
  // Common fields shared across all states
  const lines: string[] = [
    `Task: ${task.title}`,
    `ID: ${task.id}`,
    `Status: ${task.status}`,
    `Created: ${task.createdAt}`,
  ];

  // Append state-specific fields only after narrowing
  switch (task.status) {
    case "draft":
      break;
    case "active":
      lines.push(`Started: ${task.startedAt}`);
      break;
    case "in-review":
      lines.push(`Started: ${task.startedAt}`);
      lines.push(`Review Requested: ${task.reviewRequestedAt}`);
      break;
    case "completed":
      lines.push(`Started: ${task.startedAt}`);
      lines.push(`Completed: ${task.completedAt}`);
      lines.push(`Duration: ${task.duration} seconds`);
      break;
    case "cancelled":
      lines.push(`Cancelled: ${task.cancelledAt}`);
      lines.push(`Reason: ${task.reason}`);
      break;
    default:
      assertNever(task);
  }

  return lines.join("\n");
}

What does this code do?

Both functions switch on task.status (the discriminant). Inside each case, TypeScript narrows the type to the specific state interface. That's why you can safely access task.duration in the "completed" case without a type assertion.

The assertNever(task) in the default case is your exhaustiveness guard. If you ever add a sixth state to the Task union but forget to handle it here, TypeScript will refuse to compile.

  • Save src/display.ts.

Now the final piece. You need to replace the placeholder src/index.ts with a CLI entry point that routes terminal arguments to the correct command function.

  • Open src/index.ts and replace everything with the following:
import { createTask, startTask, reviewTask, completeTask, cancelTask, listTasks, showTask } from "./commands.js";

// Parse command-line arguments (skip "node" and the script path)
const args = process.argv.slice(2);
const command = args[0];

switch (command) {
  case "create":
    if (!args[1]) {
      console.error("Usage: create <title>");
      process.exit(1);
    }
    createTask(args.slice(1).join(" "));
    break;
  case "start":
    if (!args[1]) {
      console.error("Usage: start <task-id>");
      process.exit(1);
    }
    startTask(args[1]);
    break;
  case "review":
    if (!args[1]) {
      console.error("Usage: review <task-id>");
      process.exit(1);
    }
    reviewTask(args[1]);
    break;
  case "complete":
    if (!args[1]) {
      console.error("Usage: complete <task-id>");
      process.exit(1);
    }
    completeTask(args[1]);
    break;
  case "cancel":
    if (!args[1] || !args[2]) {
      console.error('Usage: cancel <task-id> <reason>');
      process.exit(1);
    }
    cancelTask(args[1], args.slice(2).join(" "));
    break;
  case "list":
    listTasks(args[1]);
    break;
  case "show":
    if (!args[1]) {
      console.error("Usage: show <task-id>");
      process.exit(1);
    }
    showTask(args[1]);
    break;
  default:
    console.log("Task Workflow Engine");
    console.log("Commands: create, start, review, complete, cancel, list, show");
    break;
}

What does this code do?

The process.argv.slice(2) skips the first two arguments (the node binary path and the script path) to get only what the user typed.

Each case validates that required arguments are present before calling the command function. The default case prints a help message listing available commands.

  • Save src/index.ts.
  • Build the project by running:
npm run build
  • Verify the build succeeds with no errors, then test the CLI by running:
npm run start

You should see the help message: Task Workflow Engine followed by the list of commands.

Build failing with errors?

If you see an error about missing ./display.js or ./store.js, make sure you created both src/display.ts and src/store.ts in the src/ directory (not the project root).

If you see type errors inside one of the files, double check the imports match exactly. Remember that TypeScript with nodenext module resolution requires .js extensions in import paths, even though the source files are .ts.

help me fix TypeScript build errors in my workflow engine project

✔️ Awesome, I've got everything!

Great. Make sure you've saved both src/display.ts and src/index.ts.

ⓧ I'd like to double check the full code

import { Task, assertNever } from "./types.js";

export function formatTask(task: Task): string {
  const base = `[${task.status.toUpperCase()}] ${task.id.slice(0, 8)} - ${task.title}`;

  switch (task.status) {
    case "draft":
      return `${base} (created: ${task.createdAt})`;
    case "active":
      return `${base} (started: ${task.startedAt})`;
    case "in-review":
      return `${base} (review requested: ${task.reviewRequestedAt})`;
    case "completed":
      return `${base} (completed: ${task.completedAt}, took ${task.duration}s)`;
    case "cancelled":
      return `${base} (cancelled: ${task.cancelledAt}, reason: ${task.reason})`;
    default:
      return assertNever(task);
  }
}

export function showTaskDetails(task: Task): string {
  const lines: string[] = [
    `Task: ${task.title}`,
    `ID: ${task.id}`,
    `Status: ${task.status}`,
    `Created: ${task.createdAt}`,
  ];

  switch (task.status) {
    case "draft":
      break;
    case "active":
      lines.push(`Started: ${task.startedAt}`);
      break;
    case "in-review":
      lines.push(`Started: ${task.startedAt}`);
      lines.push(`Review Requested: ${task.reviewRequestedAt}`);
      break;
    case "completed":
      lines.push(`Started: ${task.startedAt}`);
      lines.push(`Completed: ${task.completedAt}`);
      lines.push(`Duration: ${task.duration} seconds`);
      break;
    case "cancelled":
      lines.push(`Cancelled: ${task.cancelledAt}`);
      lines.push(`Reason: ${task.reason}`);
      break;
    default:
      assertNever(task);
  }

  return lines.join("\n");
}
import { createTask, startTask, reviewTask, completeTask, cancelTask, listTasks, showTask } from "./commands.js";

const args = process.argv.slice(2);
const command = args[0];

switch (command) {
  case "create":
    if (!args[1]) {
      console.error("Usage: create <title>");
      process.exit(1);
    }
    createTask(args.slice(1).join(" "));
    break;
  case "start":
    if (!args[1]) {
      console.error("Usage: start <task-id>");
      process.exit(1);
    }
    startTask(args[1]);
    break;
  case "review":
    if (!args[1]) {
      console.error("Usage: review <task-id>");
      process.exit(1);
    }
    reviewTask(args[1]);
    break;
  case "complete":
    if (!args[1]) {
      console.error("Usage: complete <task-id>");
      process.exit(1);
    }
    completeTask(args[1]);
    break;
  case "cancel":
    if (!args[1] || !args[2]) {
      console.error('Usage: cancel <task-id> <reason>');
      process.exit(1);
    }
    cancelTask(args[1], args.slice(2).join(" "));
    break;
  case "list":
    listTasks(args[1]);
    break;
  case "show":
    if (!args[1]) {
      console.error("Usage: show <task-id>");
      process.exit(1);
    }
    showTask(args[1]);
    break;
  default:
    console.log("Task Workflow Engine");
    console.log("Commands: create, start, review, complete, cancel, list, show");
    break;
}

Your workflow engine now has persistence, commands, and a CLI entry point. Next up, you'll run the full workflow end-to-end and prove the type system catches invalid operations at compile time.

Prove the Type System Works

You have a complete workflow engine with types, a state machine, persistence, display logic, and CLI commands. Everything compiles cleanly.

But compiling without errors only proves your code is internally consistent. In this step, you will run the full task lifecycle end-to-end, then intentionally break things to watch TypeScript catch bugs that would otherwise only surface at runtime.

In this step, get ready to:
  • Run a task through the full lifecycle from Draft to Completed.
  • Intentionally break the code three different ways and verify TypeScript catches each one at compile time.
  • Test the show command to verify type narrowing displays state-specific fields.
Run the full task lifecycle

A task in your engine moves through five possible states, but the happy path is Draft, Active, InReview, then Completed. You will push a task through that entire chain and verify the JSON file gains the correct state-specific fields at each step.

  • First, build your project by running:
npm run build
  • Create a new task by running:
node dist/index.js create "Design homepage"

You should see output like Created task: a1b2c3d4 - "Design homepage". The first 8 characters are your task's unique ID.

  • Copy that 8-character ID from the output. You will use the full UUID (visible in tasks.json) for the next commands.
  • Open tasks.json in your editor to see the task stored as a Draft with only id, status, title, and createdAt.
  • Start the task by running this command (replace the ID with your full task UUID from tasks.json):
node dist/index.js start [[TASK_ID="your-full-task-uuid-from-tasks-json"]]
  • Send the task to review by running:
node dist/index.js review [[TASK_ID="your-full-task-uuid-from-tasks-json"]]
  • Complete the task by running:
node dist/index.js complete [[TASK_ID="your-full-task-uuid-from-tasks-json"]]

You should see output showing the task completed along with a duration in seconds.

  • Open tasks.json again and verify the task now has all the fields: startedAt, completedAt, and duration.

What should I see in tasks.json?

The completed task should have status set to "completed" along with timestamps for createdAt, startedAt, and completedAt. The duration field shows how many seconds elapsed between starting and completing.

Each transition added exactly the fields defined in that state's interface. The type system guaranteed this at compile time.

Seeing "Task not found"?

Make sure you are copying the full UUID from tasks.json (not just the 8-character preview from the console output). The start, review, and complete commands need the full ID to find the task.

Also check that you run npm run build before each node dist/index.js call if you make any code changes.

Help me debug a "Task not found" error in my workflow engine.

Break the code to test compile-time safety

Running the workflow proves your code works at runtime. The real power of this project is what happens when you write invalid code. TypeScript should reject it before it ever runs.

You will try three different mistakes and confirm that npx tsc catches each one.

  • Create a temporary test file by running:
touch src/test-errors.ts
  • Open src/test-errors.ts and paste this code that tries to access a Completed field on a Draft task:
// Test 1: Accessing state-specific data without narrowing
import { Task, DraftTask } from "./types.js";
import { transitionTask } from "./machine.js";

const draft: DraftTask = {
  id: "test-id",
  status: "draft",
  title: "Test",
  createdAt: new Date().toISOString(),
};

// ERROR: Property 'completedAt' does not exist on type 'DraftTask'
console.log(draft.completedAt);

// Test 2: Invalid state transition (Draft directly to Completed)
// ERROR: Type '"completed"' is not assignable to parameter
const invalid = transitionTask(draft, "completed");

What does this code test?

Test 1 tries to read completedAt on a DraftTask. That field only exists on CompletedTask. Without narrowing the type first, TypeScript rejects the access.

Test 2 tries to jump from "draft" directly to "completed". The ValidTransition type constraint only allows "active" or "cancelled" from Draft, so the compiler rejects it.

  • Try to compile the project by running:
npx tsc

You should see two type errors in the output. One about completedAt not existing on DraftTask, and one about "completed" not being assignable.

Why does this matter?

In a traditional runtime-checked system, both mistakes would silently produce undefined or crash mid-execution. Here, TypeScript catches them before the code ever runs. These are entire categories of bugs eliminated permanently.

Now test the third error: exhaustiveness checking.

  • In src/display.ts, find the formatTask function's switch statement.
  • Temporarily delete (or comment out) the case "cancelled": block so the switch no longer handles all five states.
  • Run the compiler again:
npx tsc

You should see a new error in display.ts saying that CancelledTask is not assignable to type never in the default branch.

How does exhaustiveness checking work?

The assertNever function in the default case accepts type never. If every union member is handled by a preceding case, the value reaching default narrows to never and the call is valid.

When you remove a case, the unhandled member (CancelledTask) can reach the default branch. Since CancelledTask is not assignable to never, the compiler flags it. This guarantees you can never forget to handle a state.

  • Restore the case "cancelled": block in src/display.ts so your code is back to its working state.
  • Delete the temporary test file by running:
rm src/test-errors.ts
  • Confirm everything compiles cleanly again by running:
npx tsc

The compiler should produce no errors this time.

Still seeing errors after restoring?

Make sure you fully restored the case "cancelled": block including its return statement in formatTask. Check that src/test-errors.ts no longer exists in the src/ folder.

Help me fix lingering TypeScript compilation errors after I restored my code.

Test the show command with type-narrowed output

The show command uses the showTaskDetails function in src/display.ts. This function demonstrates type narrowing in action. It switches on task.status and pushes only the fields that exist for that state.

  • Run the show command on your completed task by running:
node dist/index.js show [[TASK_ID="your-full-task-uuid-from-tasks-json"]]

You should see the task's full details printed line by line, including the title, ID, status, created date, started date, completed date, and duration.

Why does type narrowing matter for display?

Inside the case "completed": branch, TypeScript knows the task is a CompletedTask. You can safely access task.completedAt and task.duration without any type assertions.

In the case "draft": branch, those fields do not exist on the narrowed type. Attempting to access them would produce a compile-time error. The switch statement doubles as both control flow and a type-narrowing mechanism.

  • Create a second task, then cancel it so you can compare the show output across different states:
node dist/index.js create "Write unit tests"
node dist/index.js cancel [[TASK_ID_2="your-second-task-full-uuid"]] "Deprioritized for now"
  • Run show on the cancelled task:
node dist/index.js show [[TASK_ID_2="your-second-task-full-uuid"]]

This time you should see a Cancelled date and a Reason field instead of completion data. Each state shows exactly the data that belongs to it.

  • Run the list command to see all tasks with their current statuses:
node dist/index.js list

You should see both tasks listed with their status labels, timestamps, and state-specific details.

Show command printing undefined fields?

If you see undefined in the output, your tasks.json might have stale data from a previous test run. Delete tasks.json and start fresh with a new create command.

My show command is displaying undefined for some task fields.

Your workflow engine is fully operational. TypeScript proved it prevents invalid transitions at compile time, guarantees exhaustive state handling, and narrows types so you only access data that actually exists. That is the kind of type-level correctness that separates production-grade TypeScript from JavaScript with extra syntax.

Secret mission

Make It Generic with a Second Entity

Your state machine works perfectly for Tasks. But what if you need to track Bug Reports with completely different states? Can your type system handle a second entity without duplicating the core logic? Refactor your engine into a generic module and prove that the same compile-time safety applies to any entity you throw at it.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The workflow-engine project directory on your Desktop (source code, compiled output, and node_modules).
  • The tasks.json file storing your task data (inside the project directory).
  • The bugs.json file storing bug report data, if you completed the Secret Mission (inside the project directory).

Keep everything running

No action needed. Choose this if you're still actively building or want to extend the project with more entity types.

  • Your project stays on your Desktop, ready for you to build and run commands anytime.
  • The tasks.json and bugs.json files persist your workflow data between sessions.
  • Node.js and your code editor remain installed on your machine from before this project.

Pause - I'll come back to this later

There are no running processes to stop. Since this is a CLI tool, nothing runs in the background.

  • Your project files stay on disk and take up minimal space.
  • When you return, just open the workflow-engine folder in your editor and run npm run build to recompile.

Delete - I don't want to use this again

Remove all project files from your machine. This deletes your source code, compiled output, and all task/bug data permanently.

  • Delete the project directory by running this command (macOS/Linux):
cd ~/Desktop
rm -rf workflow-engine
  • On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force workflow-engine
  • This removes all source files, the dist/ compiled output, node_modules/, and the tasks.json and bugs.json data files.

Nice Work!

Nice Work!

You just built a type-safe CLI workflow engine where TypeScript itself prevents invalid state transitions. No runtime checks needed. The compiler catches mistakes before your code ever runs.

Here's what you learned:

  • Built a CLI workflow engine using discriminated unions to model 5 task states, where each state carries only the data relevant to that lifecycle stage.
  • Wrote a generic transition function with constrained generics that makes invalid state transitions a compile-time error instead of a runtime bug.
  • Used type narrowing and exhaustiveness checking with never to guarantee every possible state is handled safely at compile time.
  • Secret Mission: Extracted the state machine into a generic module and proved it works for a completely different entity (Bug Reports) with zero code duplication in the core logic.

Ready to quiz yourself?