Two todos can have the same title. Give each one an ID so editing, moving, or deleting one never selects the other.
Describe a Todo in src/domain.ts
export const TodoId = Schema.String.pipe(Schema.brand("TodoId"));
export type TodoId = typeof TodoId.Type;
export const Todo = Schema.Struct({
id: TodoId,
text: Schema.String,
completed: Schema.Boolean,
timestamp: Schema.DateTimeUtcFromString,
});
export type TodoJson = typeof Todo.Encoded;
export type Todo = typeof Todo.Type;
export const TodoList = Schema.Array(Todo);
export type TodoListJson = typeof TodoList.Encoded;
export type TodoList = typeof TodoList.Type;
TodoId brands a string: TypeScript can distinguish an ID from a title. Todo describes the fields we accept, and its inferred type keeps the runtime schema and TypeScript model together. The timestamp codec reads a string into a UTC value; we will use it when loading saved data.
Keep committed text here. An unfinished edit belongs to the row, because cancelling it should leave this value alone.
Find an item by ID
export function updateTodo(list: TodoList, id: TodoId, f: (todo: Todo) => Todo): TodoList {
return list.map((todo) => (todo.id === id ? f(todo) : todo));
}
export function editText(id: TodoId, text: string) {
return (list: TodoList): TodoList => updateTodo(list, id, (todo) => ({ ...todo, text }));
}
updateTodo returns a new array, changing only the matching item. Items with other IDs keep their object identity. editText supplies the particular change without repeating the lookup.
Give another action the same rule
export function toggleCompleted(id: TodoId) {
return (list: TodoList): TodoList =>
updateTodo(list, id, (todo) => ({ ...todo, completed: !todo.completed }));
}
export function deleteTodo(id: TodoId) {
return (list: TodoList): TodoList => list.filter((todo) => todo.id !== id);
}
Toggling changes one completion flag. Deleting removes one ID. Neither function needs a DOM, a state container, or a browser event, so both are easy to test with ordinary values.
Check identity now
Make two todos with identical text and different IDs. Toggle one. Which object should remain the same?
Add the test file below, install Vitest with npm install --save-dev vitest, and run npm exec vitest -- run src/domain.test.ts. It tests this domain.ts, including unchanged input and retained identity.
Expected result
Only the requested ID changes. The other object is retained, and the original array still describes two incomplete todos. Change the lookup to compare text and watch this test catch the mistake.
The full file also includes the count and filter helpers we will introduce when their controls appear. Next, give the application a place to hold the current list.
Complete files
Keep the files from the previous step and replace or add these. Each full file is the source used by this milestone; the excerpts above select lines from it.
src/domain.ts
import * as Schema from "effect/Schema";
export const TodoId = Schema.String.pipe(Schema.brand("TodoId"));
export type TodoId = typeof TodoId.Type;
export const Todo = Schema.Struct({
id: TodoId,
text: Schema.String,
completed: Schema.Boolean,
timestamp: Schema.DateTimeUtcFromString,
});
export type TodoJson = typeof Todo.Encoded;
export type Todo = typeof Todo.Type;
export const TodoList = Schema.Array(Todo);
export type TodoListJson = typeof TodoList.Encoded;
export type TodoList = typeof TodoList.Type;
export const FilterState = Schema.Literals(["all", "active", "completed"]);
export type FilterState = typeof FilterState.Type;
export function updateTodo(list: TodoList, id: TodoId, f: (todo: Todo) => Todo): TodoList {
return list.map((todo) => (todo.id === id ? f(todo) : todo));
}
export function editText(id: TodoId, text: string) {
return (list: TodoList): TodoList => updateTodo(list, id, (todo) => ({ ...todo, text }));
}
export function toggleCompleted(id: TodoId) {
return (list: TodoList): TodoList =>
updateTodo(list, id, (todo) => ({ ...todo, completed: !todo.completed }));
}
export function isCompleted(todo: Todo): boolean {
return todo.completed;
}
export function isActive(todo: Todo): boolean {
return !todo.completed;
}
export function toggleAllCompleted(list: TodoList): TodoList {
if (list.some(isActive)) {
return list.map((todo) => ({ ...todo, completed: true }));
} else {
return list.map((todo) => ({ ...todo, completed: false }));
}
}
export function deleteTodo(id: TodoId) {
return (list: TodoList): TodoList => list.filter((todo) => todo.id !== id);
}
export function clearCompleted(list: TodoList): TodoList {
return list.filter(isActive);
}
export function activeCount(list: TodoList): number {
return list.filter(isActive).length;
}
export function completedCount(list: TodoList): number {
return list.filter(isCompleted).length;
}
export function allAreCompleted(list: TodoList): boolean {
return list.length > 0 && list.every(isCompleted);
}
export function someAreCompleted(list: TodoList): boolean {
return list.some(isCompleted);
}
export function filterTodoList({ list, state }: { list: TodoList; state: FilterState }): TodoList {
switch (state) {
case "all":
return list;
case "active":
return list.filter(isActive);
case "completed":
return list.filter(isCompleted);
}
}
export function updateText(text: string) {
return (todo: Todo): Todo => ({ ...todo, text });
}
src/domain.test.ts
import { DateTime } from "effect";
import { describe, expect, it } from "vitest";
import { TodoId, type TodoList, toggleCompleted } from "./domain.js";
describe("Todo domain", () => {
it("toggles only the requested Todo", () => {
const timestamp = DateTime.makeUnsafe("2026-01-01T00:00:00Z");
const first = TodoId.make("first");
// Matching titles force this test to distinguish items by ID.
const todos: TodoList = [
{ id: first, text: "Learn Typed", completed: false, timestamp },
{ id: TodoId.make("second"), text: "Learn Typed", completed: false, timestamp },
];
const next = toggleCompleted(first)(todos);
expect(next.map(({ completed }) => completed)).toEqual([true, false]);
expect(todos.map(({ completed }) => completed)).toEqual([false, false]);
expect(next[1]).toBe(todos[1]);
expect(toggleCompleted(first)([])).toEqual([]);
});
});