Overview
A task manager is the natural first TypeScript project because the whole idea behind it — a list of things, each with a handful of fields — is exactly what interfaces exist to describe. Where a plain JavaScript to-do app just trusts that every object in the array happens to have a `text` and a `completed` flag, this version has the compiler check that guarantee for you, on every single line that touches a task.
The core idea this project teaches is type narrowing: a task's `status` field is not a loose string, it is a union of exactly three literal values (`'todo'`, `'in-progress'`, `'done'`). Once a variable is typed that way, TypeScript can follow a `switch` statement branch by branch and know exactly which of the three values you are handling in each case — and it will refuse to compile if you ever forget one.
- A `Task` interface and a `TaskStatus` union type that together describe every task's exact shape.
- A form that adds new, fully-typed tasks to an in-memory `Task[]` array.
- A `renderTasks()` function that rebuilds the list from that typed array on every change.
- A status button that cycles each task through `todo` -> `in-progress` -> `done` using a narrowing `switch`.
- A delete button, wired through the same typed, delegated click handler as the status button.
Prerequisites
- Solid JavaScript — DOM basics, `addEventListener`, arrays (`push`, `filter`, `find`, `forEach`), and plain objects.
- This is your first TypeScript project, so you should already know the basic type annotation syntax: `let x: number`, `function f(x: string): void`.
- What an `interface` is at a glance — a named description of an object's shape, checked at compile time only (it produces no runtime code at all).
- That TypeScript needs a build step: a `.ts` file is compiled ("transpiled") to plain `.js` by the `tsc` command before a browser can run it, and every type annotation is stripped out in that process.
Project Structure
This project uses three files: `index.html` for the page shell, `styles.css` for the visual styling, and `script.ts` — a real TypeScript file — for all of the behavior. In a real project you would run `tsc script.ts` (or a bundler configured for TypeScript) to produce a `script.js` that `index.html` actually loads; every code block from here on shows the `.ts` source exactly as you would write it in an editor with type-checking turned on.
Just like a plain JavaScript version, the `tasks` array is the single source of truth and the visible `<ul>` is always rebuilt from it. What TypeScript adds on top is a guarantee: every object that ever enters that array is checked against the `Task` interface at compile time, so a typo like `status: 'Done'` (capitalized) or a missing `id` field is caught before the code ever runs, not discovered later by a user staring at a broken row.
<!-- Root container for the whole task manager --><div class="task-app"> <h1>Typed Task Manager</h1>
<!-- A <form>, not just a lone button, so pressing Enter submits too --> <form id="task-form"> <input type="text" id="task-input" placeholder="What needs to get done?" autocomplete="off" /> <button type="submit">Add</button> </form>
<!-- Starts empty on purpose; renderTasks() fills this in from tasks[] --> <ul id="task-list"></ul></div>* { box-sizing: border-box; margin: 0; padding: 0;}
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.task-app { width: 100%; max-width: 460px; background: #23262e; border-radius: 10px; padding: 24px;}
.task-app h1 { margin-bottom: 16px; font-size: 1.5rem;}
#task-form { display: flex; gap: 8px; margin-bottom: 16px;}
#task-input { flex: 1; padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #1a1d23; color: #e8e8e8; font-size: 1rem;}
#task-form button { padding: 10px 16px; border: none; border-radius: 6px; background: #4f8cff; color: white; cursor: pointer; font-weight: bold;}
#task-list { list-style: none;}
.task-item { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid #3a3f4b;}
.task-item span { flex: 1;}
.status-todo .status-btn { background: #3a3f4b; }.status-in-progress .status-btn { background: #c99a2e; }.status-done .status-btn { background: #2e9e5b; }.status-done span { text-decoration: line-through; opacity: 0.6; }
.status-btn, .delete-btn { border: none; border-radius: 6px; color: white; padding: 6px 10px; cursor: pointer; font-size: 0.85rem;}
.delete-btn { background: transparent; color: #ff6b6b;}Step 1: Define the Task Interface and Status Union
Everything else in this project is built on two type declarations: a `TaskStatus` union that lists the only three valid statuses a task can have, and a `Task` interface that reuses that union for its `status` field instead of the wider (and much less useful) `string`.
// A union of string literals — TypeScript will only allow these three exact// strings, catching typos like "Todo" or "done " at compile time instead of// silently creating a task nothing in the UI can ever matchtype TaskStatus = 'todo' | 'in-progress' | 'done';
// An interface describes the *shape* every task object must have. Using a// named interface (rather than scattering loose variables) means every// function that touches a task gets full autocomplete and compile-time// checking on task.id, task.text, and task.statusinterface Task { id: number; text: string; status: TaskStatus; // reuses the union above instead of "string", so an invalid status is a type error right at the object literal}A `type` alias and an `interface` can often describe the same shape, but `TaskStatus` has to be a `type` — a union of literals isn't something an `interface` can express — while `Task` is deliberately an `interface`, since it is a plain object shape and interfaces are the conventional choice for that in TypeScript.
Step 2: Select Elements and Type the State
`document.getElementById` returns the generic type `HTMLElement | null`, because TypeScript has no way to know at compile time whether an element with that id actually exists, or what kind of element it is. A type assertion (`as HTMLFormElement`) tells the compiler what you already know from reading the HTML, so later lines can use `.elements`, `.value`, and other properties specific to that element type.
// Type assertions narrow "HTMLElement | null" down to the specific element// type each variable actually holds, unlocking properties like .value that// only exist on HTMLInputElement, not on the generic HTMLElementconst taskForm = document.getElementById('task-form') as HTMLFormElement;const taskInput = document.getElementById('task-input') as HTMLInputElement;const taskList = document.getElementById('task-list') as HTMLUListElement;
// Annotating this explicitly as Task[] (rather than letting TypeScript infer// the near-useless "never[]" from an empty array literal) means push()ing// anything that isn't a full, valid Task is a compile error immediatelylet tasks: Task[] = [];
// Hands out a unique id to each new task without needing a databaselet nextId = 1;Step 3: Render Tasks from Typed State
`renderTasks` takes no arguments and returns nothing — annotating it `: void` documents that its entire job is a side effect (rewriting the DOM), not computing a value. Because `tasks` is typed as `Task[]`, the `task` parameter inside `forEach` is automatically known to be a `Task`, with every field autocompleting.
function renderTasks(): void { // ": void" documents "this does something, it doesn't return anything" taskList.innerHTML = '';
tasks.forEach(function (task: Task) { // TypeScript infers this type from Task[] automatically; written out here for clarity const li = document.createElement('li'); li.className = 'task-item status-' + task.status; // task.status can only ever be one of the three TaskStatus literals li.dataset.id = String(task.id);
const span = document.createElement('span'); span.textContent = task.text;
const statusBtn = document.createElement('button'); statusBtn.type = 'button'; statusBtn.className = 'status-btn'; statusBtn.textContent = statusLabel(task.status); // defined in Step 5 — turns the union value into readable text
const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'delete-btn'; deleteBtn.textContent = 'Delete';
li.appendChild(span); li.appendChild(statusBtn); li.appendChild(deleteBtn); taskList.appendChild(li); });}Step 4: Add a New Task
Annotating the new task object literal as `Task` means TypeScript checks it against the interface right where it is created — forgetting the `status` field, or misspelling one of its literal values, fails to compile instead of quietly producing a task the rest of the app can't handle.
taskForm.addEventListener('submit', function (event: SubmitEvent): void { event.preventDefault(); // Stop the browser's default full-page reload on form submit
const text = taskInput.value.trim(); if (text === '') { return; // Silently ignore empty submissions }
// The ": Task" annotation checks this object literal against the interface // right here — every field is required, and status can only be one of the // three TaskStatus literals const newTask: Task = { id: nextId++, text: text, status: 'todo', // every new task always starts in the same literal member of TaskStatus };
tasks.push(newTask); // tasks is Task[], so pushing anything else here would be a compile error taskInput.value = ''; renderTasks();});Click Run to see what this code prints.
Step 5: Narrow on Status to Update and Delete Tasks
`nextStatus` is where type narrowing actually pays off: because its parameter is typed `TaskStatus` (not `string`), a `switch` over it can be checked for exhaustiveness. The `default` branch assigns whatever is left to a variable typed `never` — if `TaskStatus` ever grew a fourth member and this switch weren't updated to handle it, that assignment would fail to compile, catching the gap immediately instead of at runtime.
// Cycles a task forward through its lifecycle. Typing both the parameter// and return value as TaskStatus (not string) is what makes the switch below// exhaustiveness-checkable by the compilerfunction nextStatus(current: TaskStatus): TaskStatus { switch (current) { case 'todo': return 'in-progress'; case 'in-progress': return 'done'; case 'done': return 'todo'; default: // If TypeScript ever lets a value reach here, TaskStatus grew a member // this switch doesn't handle — assigning it to "never" turns that gap // into a compile-time error instead of a silent runtime bug const _exhaustive: never = current; return _exhaustive; }}
// A second, simpler narrowing switch — no default case is needed here// because TypeScript already knows the three cases below cover every// possible TaskStatus value, so the function is provably totalfunction statusLabel(status: TaskStatus): string { switch (status) { case 'todo': return 'To Do'; case 'in-progress': return 'In Progress'; case 'done': return 'Done'; }}Step 6: Wire Everything Together
A single delegated click listener on `taskList` handles both the status button and the delete button. `event.target` is typed `EventTarget | null` by default, which has no `.closest()` method, so it is asserted to `HTMLElement` first — the same trick used for the DOM lookups in Step 2, just applied to an event instead of an id.
taskList.addEventListener('click', function (event: MouseEvent): void { const target = event.target as HTMLElement; // narrow EventTarget | null down to something with .closest() and .classList const li = target.closest('.task-item') as HTMLLIElement | null; if (!li || !li.dataset.id) return; // click landed outside any task row
const id = Number(li.dataset.id); const task = tasks.find(function (t: Task): boolean { return t.id === id; }); if (!task) return; // task is "Task | undefined" until this guard narrows it to just "Task" below
if (target.classList.contains('delete-btn')) { tasks = tasks.filter(function (t: Task): boolean { return t.id !== id; }); } else if (target.classList.contains('status-btn')) { task.status = nextStatus(task.status); // reuses the narrowing switch from Step 5 } else { return; // click was on the row but not on either button }
renderTasks();});
// Draw the initial (empty) list as soon as the script runsrenderTasks();Complete Code
Here is the complete project — the HTML shell, the CSS, and the fully assembled `script.ts` in the order you would put them in real files. Remember that this `.ts` file has to be run through `tsc` (or a TypeScript-aware bundler) before a browser can execute it; the interactive demo right after this section runs the type-erased JavaScript output, which is why its inline script has no type annotations at all — types are a compile-time-only tool, and there is nothing left of them once transpilation strips them out.
<div class="task-app"> <h1>Typed Task Manager</h1>
<form id="task-form"> <input type="text" id="task-input" placeholder="What needs to get done?" autocomplete="off" /> <button type="submit">Add</button> </form>
<ul id="task-list"></ul></div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.task-app { width: 100%; max-width: 460px; background: #23262e; border-radius: 10px; padding: 24px;}
.task-app h1 { margin-bottom: 16px; font-size: 1.5rem; }
#task-form { display: flex; gap: 8px; margin-bottom: 16px; }
#task-input { flex: 1; padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #1a1d23; color: #e8e8e8; font-size: 1rem;}
#task-form button { padding: 10px 16px; border: none; border-radius: 6px; background: #4f8cff; color: white; cursor: pointer; font-weight: bold;}
#task-list { list-style: none; }
.task-item { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid #3a3f4b;}
.task-item span { flex: 1; }
.status-todo .status-btn { background: #3a3f4b; }.status-in-progress .status-btn { background: #c99a2e; }.status-done .status-btn { background: #2e9e5b; }.status-done span { text-decoration: line-through; opacity: 0.6; }
.status-btn, .delete-btn { border: none; border-radius: 6px; color: white; padding: 6px 10px; cursor: pointer; font-size: 0.85rem;}
.delete-btn { background: transparent; color: #ff6b6b; }type TaskStatus = 'todo' | 'in-progress' | 'done';
interface Task { id: number; text: string; status: TaskStatus;}
const taskForm = document.getElementById('task-form') as HTMLFormElement;const taskInput = document.getElementById('task-input') as HTMLInputElement;const taskList = document.getElementById('task-list') as HTMLUListElement;
let tasks: Task[] = [];let nextId = 1;
function statusLabel(status: TaskStatus): string { switch (status) { case 'todo': return 'To Do'; case 'in-progress': return 'In Progress'; case 'done': return 'Done'; }}
function nextStatus(current: TaskStatus): TaskStatus { switch (current) { case 'todo': return 'in-progress'; case 'in-progress': return 'done'; case 'done': return 'todo'; default: const _exhaustive: never = current; return _exhaustive; }}
function renderTasks(): void { taskList.innerHTML = '';
tasks.forEach(function (task: Task) { const li = document.createElement('li'); li.className = 'task-item status-' + task.status; li.dataset.id = String(task.id);
const span = document.createElement('span'); span.textContent = task.text;
const statusBtn = document.createElement('button'); statusBtn.type = 'button'; statusBtn.className = 'status-btn'; statusBtn.textContent = statusLabel(task.status);
const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'delete-btn'; deleteBtn.textContent = 'Delete';
li.appendChild(span); li.appendChild(statusBtn); li.appendChild(deleteBtn); taskList.appendChild(li); });}
taskForm.addEventListener('submit', function (event: SubmitEvent): void { event.preventDefault();
const text = taskInput.value.trim(); if (text === '') { return; }
const newTask: Task = { id: nextId++, text: text, status: 'todo', };
tasks.push(newTask); taskInput.value = ''; renderTasks();});
taskList.addEventListener('click', function (event: MouseEvent): void { const target = event.target as HTMLElement; const li = target.closest('.task-item') as HTMLLIElement | null; if (!li || !li.dataset.id) return;
const id = Number(li.dataset.id); const task = tasks.find(function (t: Task): boolean { return t.id === id; }); if (!task) return;
if (target.classList.contains('delete-btn')) { tasks = tasks.filter(function (t: Task): boolean { return t.id !== id; }); } else if (target.classList.contains('status-btn')) { task.status = nextStatus(task.status); } else { return; }
renderTasks();});
renderTasks();Sample Run
Click Run to see what this code prints.
Extend This Project
- Persist `tasks` to `localStorage`, parsing the saved JSON back with `JSON.parse(saved) as Task[]` on load.
- Add a `dueDate?: string` optional field to `Task`, and render it only for tasks where it is set.
- Build a `countByStatus(tasks: Task[]): Record<TaskStatus, number>` summary using `Record` to guarantee all three statuses are always present.
- Write a real type guard, `function isOverdue(task: Task): boolean`, and use it with `.filter()` to show only overdue tasks.
- Add a priority field typed as a second union (`'low' | 'medium' | 'high'`) and sort the rendered list by it.
Summary
You built a task manager where an `interface` and a literal union type describe every task's exact shape, and a `switch` statement narrows that union case by case, with the compiler checking that every case is actually handled. This pattern — model the valid states as a union, then narrow on it with `switch` or `if` — is the foundation the rest of the TypeScript course builds on, especially the discriminated unions used in the Form Validator project.