LearnAI ToolsCareerPractice BuildsPlayContact
TypeScriptBeginner~1.5 hours

Typed Task Manager

Build a to-do app using interfaces for tasks and a discriminated union for task status.

InterfacesUnion TypesType Narrowing

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.

What You'll Build
  • 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 match
type 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.status
interface 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 HTMLElement
const 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 immediately
let tasks: Task[] = [];
// Hands out a unique id to each new task without needing a database
let 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();
});
What Happens in the tasks Array

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 compiler
function 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 total
function 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 runs
renderTasks();

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();
Live Preview

Sample Run

Sample Interaction

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.