Type-Safe To-Do List with TypeScript
Create a strongly typed task manager using Next.js and TypeScript with reliable UI state
Time to implement the project: ~ 6-9 hours
- TypeScript
- Next.js
- Type Safety
- Interface Design
- State Management
- Material UI
In this beginner TypeScript project, you will build a task management application where every piece of data is defined and validated through strict typing. The application should be created using Next.js so pages, components, and routing follow a predictable project structure. Users must be able to create tasks, mark them as completed, and remove them from the list.
Each task should follow a clearly defined TypeScript interface that ensures consistent data handling throughout the application. Material UI will be used to construct input forms, task lists, buttons, and layout containers. The emphasis of this project is not complexity but correctness - every component should rely on typed data structures instead of loosely defined objects.
What This Project Teaches
TypeScript changes how developers think about application data. Instead of relying on runtime behavior, types define how information flows between components. This project demonstrates how strongly typed models prevent common mistakes and make code easier to maintain.
By combining TypeScript with Next.js, you will learn how typed components interact within a structured application environment. The goal is to reinforce the habit of defining interfaces, validating state updates, and keeping application data predictable.
Prerequisites for the Project
Since this is a beginner-level TypeScript project, you should already be familiar with basic JavaScript and understand how React components function inside a Next.js application.
- Basic understanding of JavaScript variables and objects
- Introductory knowledge of TypeScript types and interfaces
- Experience creating simple React components
- Understanding how Next.js organizes pages and components
- Familiarity with installing and using UI libraries
Main Functional Requirements
The application should remain simple but structured. Every interaction with the task list must respect the defined data types. This ensures the project demonstrates the value of TypeScript rather than simply reproducing a JavaScript to-do list example.
| Requirement | Explanation |
| Typed task model | A TypeScript interface must define properties such as id, title, and completion status. |
| Add task functionality | Users should be able to create new tasks through a typed input form. |
| Task completion toggle | Tasks must support a completed state that updates safely within typed state logic. |
| Task removal | Users should be able to delete tasks from the list. |
| Typed state updates | All state updates must respect TypeScript definitions to prevent invalid values. |
| Material UI layout components | Use Material UI cards, buttons, and list components for interface consistency. |
| Responsive task layout | The interface should remain usable on different screen sizes. |
| Clean project structure | Separate components, types, and pages clearly inside the Next.js project. |
Tips for Implementing a Typed Application
Start by defining your data model before writing any UI code. Create a TypeScript interface for tasks and ensure every function interacting with that data respects the same type definitions. This habit prevents inconsistent structures from appearing in different parts of the application. Next.js will provide the structural framework while Material UI handles layout consistency. Type definitions should guide development rather than appear as an afterthought.
- Define task types in a separate file to reuse across components
- Use typed props in every React component
- Avoid using "any" so type safety remains meaningful
- Organize UI elements into reusable components
- Keep state updates predictable and fully typed
- Review TypeScript errors carefully instead of ignoring them
Common Mistakes When Building a Type-Safe To-Do List
1. Using any and losing the whole point of TypeScript
A Type-Safe To-Do List should prove that you can model data clearly. The most common mistake is adding TypeScript to the project but still using any for
tasks, props, events, and state updates. The app may work, but it does not demonstrate real type safety. TypeScript becomes decoration instead of a tool that prevents
invalid data from moving through the application.
Problematic approach:
const [tasks, setTasks] = useState<any[]>([]);
function addTask(task: any) {
setTasks([...tasks, task]);
}
function TaskItem({ task }: any) {
return (
<li>
{task.name}
</li>
);
}
This code accepts anything. A task could have name, title, text, or no readable field at all. TypeScript cannot help because the
code explicitly disables useful checking.
Better approach:
export interface Task {
id: string;
title: string;
completed: boolean;
createdAt: string;
}
const [tasks, setTasks] = useState<Task[]>([]);
function addTask(title: string): void {
const newTask: Task = {
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date().toISOString()
};
setTasks((currentTasks) => [
...currentTasks,
newTask
]);
}
Typed component props:
interface TaskItemProps {
task: Task;
onToggle: (taskId: string) => void;
onDelete: (taskId: string) => void;
}
function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
return (
<li>
<span>{task.title}</span>
<button type="button" onClick={() => onToggle(task.id)}>
{task.completed ? "Undo" : "Complete"}
</button>
<button type="button" onClick={() => onDelete(task.id)}>
Delete
</button>
</li>
);
}
Pay attention to: Avoid any in beginner TypeScript projects. Define task interfaces, typed props, typed callbacks, and typed state from
the beginning.
2. Creating tasks without a stable ID
A to-do list seems simple because every task has a title and a completed state. But once users can toggle or delete tasks, every task needs a stable identifier. Beginners often use the array index as the task ID. This works until the list changes order, a task is deleted, or filters hide some items. Then the wrong task may be updated.
Problematic code:
const tasks = [
{ title: "Learn TypeScript", completed: false },
{ title: "Build todo app", completed: false }
];
function toggleTask(index: number): void {
setTasks((currentTasks) => {
return currentTasks.map((task, taskIndex) => {
if (taskIndex === index) {
return {
...task,
completed: !task.completed
};
}
return task;
});
});
}
This relies on the task position. If a filter is active or an item is removed, the index may no longer point to the task the user intended to update.
Better approach:
interface Task {
id: string;
title: string;
completed: boolean;
createdAt: string;
}
function createTask(title: string): Task {
return {
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date().toISOString()
};
}
function toggleTask(taskId: string): void {
setTasks((currentTasks) => {
return currentTasks.map((task) => {
if (task.id !== taskId) {
return task;
}
return {
...task,
completed: !task.completed
};
});
});
}
Rendering with stable keys:
{visibleTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={toggleTask}
onDelete={deleteTask}
/>
))}
Pay attention to: Use stable IDs for every task. Do not use array indexes for updates or React keys when items can be added, deleted, reordered, or filtered.
3. Mutating task objects directly instead of returning new state
Type safety does not automatically make state updates safe. A common mistake is finding a task object and changing it directly. In React and Next.js apps, direct mutation can cause confusing render behavior because the array or object reference may not change in the way React expects.
Problematic approach:
function toggleTask(taskId: string): void {
const task = tasks.find((item) => item.id === taskId);
if (task) {
task.completed = !task.completed;
}
setTasks(tasks);
}
This changes the existing task object. The code is typed, but the update pattern is still fragile because it mutates existing state.
Better immutable update:
function toggleTask(taskId: string): void {
setTasks((currentTasks) => {
return currentTasks.map((task) => {
if (task.id !== taskId) {
return task;
}
return {
...task,
completed: !task.completed
};
});
});
}
function deleteTask(taskId: string): void {
setTasks((currentTasks) => {
return currentTasks.filter((task) => task.id !== taskId);
});
}
Optional readonly model:
export interface Task {
readonly id: string;
readonly title: string;
readonly completed: boolean;
readonly createdAt: string;
}
Using readonly is not required for a beginner project, but it helps communicate that task objects should be replaced through state updates rather than
changed directly.
Pay attention to: TypeScript helps define valid data, but you still need predictable update patterns. Prefer immutable state updates for add, toggle, edit, delete, and clear-completed actions.
4. Typing filters as loose strings instead of a union type
To-do apps often include filters such as all, active, and completed. A common mistake is storing the filter as a regular string. That allows invalid values like
"done", "finished", "activee", or an empty string. The UI may render nothing or behave unpredictably, and TypeScript will not
warn you if the filter type is too loose.
Problematic code:
const [filter, setFilter] = useState<string>("all");
function getVisibleTasks(tasks: Task[]) {
if (filter === "active") {
return tasks.filter((task) => !task.completed);
}
if (filter === "completed") {
return tasks.filter((task) => task.completed);
}
return tasks;
}
setFilter("complete");
TypeScript allows "complete" because the filter is just a string, even though the app expects "completed".
Better approach:
type TaskFilter = "all" | "active" | "completed";
const [filter, setFilter] = useState<TaskFilter>("all");
function getVisibleTasks(tasks: Task[], filter: TaskFilter): Task[] {
switch (filter) {
case "active":
return tasks.filter((task) => !task.completed);
case "completed":
return tasks.filter((task) => task.completed);
case "all":
return tasks;
}
}
Typed filter options:
const filterOptions: Array<{
label: string;
value: TaskFilter;
}> = [
{ label: "All", value: "all" },
{ label: "Active", value: "active" },
{ label: "Completed", value: "completed" }
];
Material UI example:
<ToggleButtonGroup
value={filter}
exclusive
onChange={(_, nextFilter: TaskFilter | null) => {
if (nextFilter) {
setFilter(nextFilter);
}
}}
>
{filterOptions.map((option) => (
<ToggleButton key={option.value} value={option.value}>
{option.label}
</ToggleButton>
))}
</ToggleButtonGroup>
Pay attention to: Use union types for small sets of allowed values. This is one of the easiest ways to make a beginner TypeScript project feel genuinely type-safe.
5. Loading saved tasks from localStorage without validation
If you add persistence, do not assume saved data is always valid. localStorage can contain old data from a previous version, manually edited values,
corrupted JSON, or a shape that no longer matches your Task interface. TypeScript checks your code at build time, but it does not automatically validate
runtime data from browser storage.
Problematic code:
function loadTasks(): Task[] {
return JSON.parse(localStorage.getItem("tasks") || "[]");
}
const [tasks, setTasks] = useState<Task[]>(loadTasks);
This tells TypeScript the result is Task[], but it may not be true. If localStorage contains invalid data, the app can break when rendering or updating
tasks.
Better approach:
function isTask(value: unknown): value is Task {
if (!value || typeof value !== "object") {
return false;
}
const task = value as Record<string, unknown>;
return (
typeof task.id === "string" &&
typeof task.title === "string" &&
typeof task.completed === "boolean" &&
typeof task.createdAt === "string"
);
}
function loadTasks(): Task[] {
const savedTasks = localStorage.getItem("tasks");
if (!savedTasks) {
return [];
}
try {
const parsedTasks: unknown = JSON.parse(savedTasks);
if (!Array.isArray(parsedTasks)) {
return [];
}
return parsedTasks.filter(isTask);
} catch {
localStorage.removeItem("tasks");
return [];
}
}
Saving typed data:
useEffect(() => {
localStorage.setItem("tasks", JSON.stringify(tasks));
}, [tasks]);
Pay attention to: Runtime data still needs validation. Use type guards when reading from localStorage, APIs, URL params, or any external source.
By completing this project, you'll gain practical experience building a structured application with TypeScript and Next.js. You will practice defining data models, managing typed state updates, and building reliable UI components with Material UI. This project forms a strong foundation for developing larger TypeScript applications where predictable data structures and type safety are essential.
Reference Implementations Worth Studying
Beginner-friendly vanilla TypeScript reference:
xkalepar - Todo List TypeScript
This is the most beginner-friendly reference from the list because it keeps the product scope very close to the core assignment. It is a simple todo app built with Vanilla TypeScript and Vite. The README describes the essential interactions clearly: users can add new tasks, mark tasks as completed, and delete tasks.
Pay particular attention to:
- How a todo app can be built without a large framework while still using TypeScript.
- How the project keeps the feature set focused on add, complete, and delete behavior.
- How Vite provides a lightweight development setup for a beginner TypeScript project.
- How localStorage-related ideas can support persistence in a small task app.
- What you would improve in a Next.js version with typed props, Material UI components, and stricter runtime validation.
Use this repository as the simplest functional baseline. It is especially useful for understanding the minimum todo behavior before adding Next.js structure, reusable React components, and a stronger typed data model.
More architecture-focused TypeScript reference:
lifeiscontent - Functional Todo App
This implementation is the strongest reference for architecture and type-safety thinking. It is a functional and immutable todo application built with TypeScript. The
project uses a modular structure with a core Todo model, a generic store interface, a todo-specific store, view components, immutable state updates,
event-based publish/subscribe behavior, typed actions, and clear separation between view logic and state management.
When studying the code, focus on:
- How the
Todointerface becomes the core domain model instead of an informal object shape. - How immutable updates make state transitions easier to reason about.
- How typed actions create predictable state changes.
- How separating store logic from view logic keeps the app maintainable.
- How functional patterns can be adapted to React or Next.js without copying the exact implementation style.
Use this repository as the polished reference for thinking. It is not just about making a todo list work; it shows how TypeScript can shape architecture, state updates, and component responsibilities.
Alternative HTML/CSS/TypeScript implementation:
cunguyendev - Todo List TypeScript
This repository is a useful alternative because it approaches the same todo-list idea through HTML, CSS, SCSS, and TypeScript rather than a Next.js component structure. The README describes it as a base app with TypeScript, includes local setup with Parcel, and provides a deployed Netlify demo. That makes it helpful for comparing framework-free implementation patterns with a more structured Next.js version.
While reviewing this project, examine:
- How TypeScript can be used in a traditional frontend project without React or Next.js.
- How HTML, SCSS, and TypeScript responsibilities are separated.
- How a lightweight build tool such as Parcel supports local development and build output.
- How the app can be deployed as a simple static frontend.
- Which parts of the logic would become React state, typed props, and reusable components in your own version.
Use this implementation as a comparison point. It helps learners see that TypeScript is not tied to one framework; the same task model, event handling, and state logic can be expressed in different frontend architectures.