Skip to main content

Todo List Project Walkthrough

Let's implement the core logic for our Todo app, focusing on how TypeScript enforces safety at the boundaries (DOM and LocalStorage).

1. Define the Data Model

Before touching the DOM, define the shape of your domain data.

type Todo = {
id: string;
title: string;
completed: boolean;
createdAt: Date;
}

let todos: Todo[] = loadTodos();

2. DOM Selection and Casting

TypeScript knows document.getElementById returns HTMLElement | null. It does not know it is a form or an input. We must assert this using as.

// 1. Select the elements
const list = document.querySelector<HTMLUListElement>("#list");
const form = document.getElementById("new-todo-form") as HTMLFormElement | null;
const input = document.querySelector<HTMLInputElement>("#new-todo-title");

// 2. Narrow the nulls (fail fast)
if (!list || !form || !input) {
throw new Error("Critical DOM elements are missing.");
}

3. Handling Events

When we attach an event listener, TypeScript can usually infer the event type. Inside the handler, we interact with our strongly-typed DOM elements.

form.addEventListener("submit", (e: SubmitEvent) => {
e.preventDefault();

if (input.value === "" || input.value == null) return;

const newTodo: Todo = {
id: crypto.randomUUID(),
title: input.value,
completed: false,
createdAt: new Date()
};

todos.push(newTodo);
saveTodos();
renderTodo(newTodo);

input.value = ""; // Safely accessed because we cast it as HTMLInputElement earlier
});

The Local Storage Boundary

When data leaves your app (to an API or localStorage), it loses its types. When it comes back, you cannot blindly trust it. JSON.parse returns any. You must explicitly declare the type it returns.

function saveTodos() {
localStorage.setItem("TODOS", JSON.stringify(todos));
}

function loadTodos(): Todo[] {
const todoJSON = localStorage.getItem("TODOS");
if (todoJSON == null) return [];

// JSON.parse returns 'any'. We cast it back to our trusted type.
// In a real production app, you would use Zod to validate this parsing!
return JSON.parse(todoJSON) as Todo[];
}

The core pattern of client-side TypeScript: strictly define your data model, safely cast your DOM elements up front, and explicitly type data crossing serialization boundaries like LocalStorage or network requests.