LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.