The interactive example loads in the browser.
The interactive example loads in the browser.
Test the three behaviors we relied on: IDs choose the right item, creation preserves rejected drafts, and editing preserves the right row. These tests import the application you just built.
Test identity in src/domain.test.ts
Start with matching titles and different IDs:
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 },
];
Toggle one ID and check the result and the unchanged input:
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([]);
The matching item changes, the input remains unchanged, and the other item keeps its object. The empty-list assertion records what happens when the ID is absent. No browser or state service is needed.
Test creation in src/application.test.ts
Provide fresh application state for the test:
const model = Layer.mergeAll(
App.TodoList.make([]),
App.TodoText.make(""),
App.FilterState.make("all"),
);
A deterministic factory counts calls and returns predictable IDs; its complete setup is below. First, prove whitespace never reaches it:
yield* RefSubject.set(App.TodoText, " ");
yield* App.createTodo;
expect(calls).toBe(0);
expect(yield* App.TodoList).toEqual([]);
expect(yield* App.TodoText).toBe(" ");
Then submit valid text and check the committed item before checking the cleared draft:
yield* RefSubject.set(App.TodoText, " Learn Typed ");
yield* App.createTodo;
expect(calls).toBe(1);
expect(yield* App.TodoList).toEqual([
{ id: Domain.TodoId.make("todo-1"), text: "Learn Typed", completed: false, timestamp },
]);
expect(yield* App.TodoText).toBe("");
expect(yield* App.ActiveCount).toBe(1);
The second test makes the factory unavailable:
yield* RefSubject.set(App.TodoText, "Keep this draft");
const exit = yield* Effect.exit(App.createTodo);
expect(Exit.isFailure(exit)).toBe(true);
expect(yield* App.TodoList).toEqual([]);
expect(yield* App.TodoText).toBe("Keep this draft");
Our factory has no expected-error channel, so this test supplies a defect with Effect.die. It verifies that a failed factory neither partially commits nor loses the draft. A remote factory should declare its expected rejection type and decide how the UI presents it.
Test the real form in src/presentation.test.ts
The test mounts TodoApp with controlled services. A scoped renderer fiber stays subscribed during the test; a Deferred readiness signal waits for its first emission without ending it. Its helpers reproduce the events our handlers consume:
// Assigning .value alone does not emit the input event our application observes.
const type = (input: HTMLInputElement, value: string) => {
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
};
const submit = () =>
host
.querySelector(".add-todo")!
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
Changing .value alone would not notify the application. After the first submission, retain its row. Prepend another todo and check that the retained row merely moved:
const draft = host.querySelector<HTMLInputElement>(".new-todo")!;
type(draft, "Same title");
submit();
await vi.waitFor(() => expect(host.querySelectorAll(".todo-list > li")).toHaveLength(1));
// Retain the node: equal labels cannot prove that keyed identity survived.
const original = host.querySelector(".todo-list > li")!;
type(draft, "Same title");
submit();
await vi.waitFor(() => expect(host.querySelectorAll(".todo-list > li")).toHaveLength(2));
expect(host.querySelectorAll(".todo-list > li")[1]).toBe(original);
Now edit that row and press Escape:
original.querySelector<HTMLButtonElement>(".edit-trigger")!.click();
await vi.waitFor(() => expect(original.querySelector(".edit")).not.toBeNull());
const edit = original.querySelector<HTMLInputElement>(".edit")!;
type(edit, "Uncommitted text");
edit.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await vi.waitFor(() => expect(original.querySelector(".edit")).toBeNull());
expect(original.querySelector(".view label")?.textContent).toBe("Same title");
expect(host.querySelectorAll(".todo-list > li")[1]).toBe(original);
The editor disappears, its abandoned text never becomes committed text, and the same row remains. vi.waitFor waits for these observable results instead of guessing how long rendering takes. The full test uses an Effect Scope for renderer cleanup and finally to remove its host.
Run the complete tests
Copy the three test files below into src, then run:
npm install --save-dev vitest happy-dom
npx vitest run src/domain.test.ts src/application.test.ts src/presentation.test.ts
The domain and application tests run without a DOM environment. The presentation file’s first line selects Happy DOM. Use a real browser for focus, layout, and keyboard usability, and for the assembled create → edit → complete → filter → clear → reload flow. The preview above runs the chapter’s own storage key.
Continue with testing Typed systems for controlled time and lifetimes, or building UI components for the same techniques applied to another interaction.
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.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([]);
});
});
src/application.test.ts
import { DateTime, Effect, Exit, Layer } from "effect";
import { RefSubject } from "@typed/fx";
import { describe, expect, it } from "vitest";
import * as App from "./application.js";
import * as Domain from "./domain.js";
const timestamp = DateTime.makeUnsafe("2026-01-01T00:00:00Z");
const model = Layer.mergeAll(
App.TodoList.make([]),
App.TodoText.make(""),
App.FilterState.make("all"),
);
describe("createTodo", () => {
it("ignores blank drafts and commits trimmed text before clearing the input", () => {
// A blank draft must not reach the factory, even if it leaves the list unchanged.
let calls = 0;
const create = Layer.succeed(App.CreateTodo, (text) =>
Effect.sync((): Domain.Todo => ({
id: Domain.TodoId.make(`todo-${++calls}`),
text,
completed: false,
timestamp,
})),
);
return Effect.gen(function* () {
yield* RefSubject.set(App.TodoText, " ");
yield* App.createTodo;
expect(calls).toBe(0);
expect(yield* App.TodoList).toEqual([]);
expect(yield* App.TodoText).toBe(" ");
yield* RefSubject.set(App.TodoText, " Learn Typed ");
yield* App.createTodo;
expect(calls).toBe(1);
expect(yield* App.TodoList).toEqual([
{ id: Domain.TodoId.make("todo-1"), text: "Learn Typed", completed: false, timestamp },
]);
expect(yield* App.TodoText).toBe("");
expect(yield* App.ActiveCount).toBe(1);
}).pipe(Effect.provide([model, create]), Effect.scoped, Effect.runPromise);
});
it("preserves the draft when the factory cannot create an item", () =>
Effect.gen(function* () {
yield* RefSubject.set(App.TodoText, "Keep this draft");
const exit = yield* Effect.exit(App.createTodo);
expect(Exit.isFailure(exit)).toBe(true);
expect(yield* App.TodoList).toEqual([]);
expect(yield* App.TodoText).toBe("Keep this draft");
}).pipe(
Effect.provide([
model,
Layer.succeed(App.CreateTodo, () => Effect.die("ID factory unavailable")),
]),
Effect.scoped,
Effect.runPromise,
));
});
src/presentation.test.ts
// @vitest-environment happy-dom
import { DateTime, Deferred, Effect, Layer } from "effect";
import { Fx } from "@typed/fx";
import { ServerRouter } from "@typed/router";
import { DomRenderTemplate, render } from "@typed/template";
import { expect, it, vi } from "vitest";
import * as App from "./application.js";
import * as Domain from "./domain.js";
import { TodoApp } from "./presentation.js";
it("keeps the keyed row and discards its edit draft on Escape", async () => {
const host = document.createElement("div");
document.body.append(host);
let nextId = 0;
const services = Layer.mergeAll(
App.TodoList.make([]),
App.TodoText.make(""),
App.FilterState.make("all"),
ServerRouter({ url: "https://tutorial.local/" }),
Layer.succeed(App.CreateTodo, (text) =>
Effect.sync((): Domain.Todo => ({
id: Domain.TodoId.make(`todo-${++nextId}`),
text,
completed: false,
timestamp: DateTime.makeUnsafe("2026-01-01T00:00:00Z"),
})),
),
);
// Assigning .value alone does not emit the input event our application observes.
const type = (input: HTMLInputElement, value: string) => {
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
};
const submit = () =>
host
.querySelector(".add-todo")!
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
try {
await Effect.gen(function* () {
const ready = yield* Deferred.make<void>();
yield* render(TodoApp, host).pipe(
Fx.observe(() => Deferred.succeed(ready, undefined)),
Effect.scoped,
Effect.forkScoped,
);
yield* Deferred.await(ready);
yield* Effect.promise(async () => {
const draft = host.querySelector<HTMLInputElement>(".new-todo")!;
type(draft, "Same title");
submit();
await vi.waitFor(() => expect(host.querySelectorAll(".todo-list > li")).toHaveLength(1));
// Retain the node: equal labels cannot prove that keyed identity survived.
const original = host.querySelector(".todo-list > li")!;
type(draft, "Same title");
submit();
await vi.waitFor(() => expect(host.querySelectorAll(".todo-list > li")).toHaveLength(2));
expect(host.querySelectorAll(".todo-list > li")[1]).toBe(original);
original.querySelector<HTMLButtonElement>(".edit-trigger")!.click();
await vi.waitFor(() => expect(original.querySelector(".edit")).not.toBeNull());
const edit = original.querySelector<HTMLInputElement>(".edit")!;
type(edit, "Uncommitted text");
edit.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await vi.waitFor(() => expect(original.querySelector(".edit")).toBeNull());
expect(original.querySelector(".view label")?.textContent).toBe("Same title");
expect(host.querySelectorAll(".todo-list > li")[1]).toBe(original);
});
}).pipe(
Effect.provide([services, DomRenderTemplate.using(document)]),
Effect.scoped,
Effect.runPromise,
);
} finally {
host.remove();
}
});
src/infrastructure.ts
import { DateTime, Effect, Layer, Context, Schema } from "effect";
import { Fx } from "@typed/fx";
import * as Router from "@typed/router";
import * as App from "./application.js";
import * as Domain from "./domain.js";
const TODOS_STORAGE_KEY = `@typed/tutorial/todo-10`;
// Parsed JSON is not yet a TodoList; decode at the storage boundary.
const TodoListJson = Schema.fromJsonString(Schema.toCodecJson(Domain.TodoList));
const decodeTodoList = Schema.decodeEffect(TodoListJson);
const encodeTodoList = Schema.encodeEffect(TodoListJson);
class Todos extends Context.Service<
Todos,
{
readonly load: Effect.Effect<Domain.TodoList, unknown>;
readonly save: (todos: Domain.TodoList) => Effect.Effect<void, unknown>;
}
>()("TodosService") {
static readonly get = Todos.pipe(
Effect.flatMap((service) => service.load),
Effect.catchCause(() => Effect.succeed([])),
);
static readonly set = (todos: Domain.TodoList) =>
Effect.flatMap(Todos, (service) => service.save(todos)).pipe(
Effect.catchCause((cause) =>
Effect.logError("Failed to write todos to key value store", cause),
),
);
static readonly replicateToStorage = App.TodoList.pipe(Fx.observeLayer(Todos.set));
static readonly local = Layer.succeed(Todos, {
load: Effect.try(() => localStorage.getItem(TODOS_STORAGE_KEY)).pipe(
Effect.flatMap((value) =>
value === null ? Effect.succeed<Domain.TodoList>([]) : decodeTodoList(value),
),
),
save: (todos) =>
encodeTodoList(todos).pipe(
Effect.flatMap((value) => Effect.try(() => localStorage.setItem(TODOS_STORAGE_KEY, value))),
),
});
}
const FilterState = Router.match(Router.Slash, "all")
.match(Router.Parse("active"), "active")
.match(Router.Parse("completed"), "completed")
.redirectTo("/")
.pipe(
Fx.catchCause(() => Fx.succeed("all" as const)),
);
// Load before observing changes, so an empty default cannot overwrite saved items.
const Model = Layer.mergeAll(
App.TodoList.make(Todos.get),
App.FilterState.make(FilterState),
App.TodoText.make(""),
);
const CreateTodo = Layer.sync(
App.CreateTodo,
() => (text: string) =>
Effect.sync((): Domain.Todo => ({
id: Domain.TodoId.make(crypto.randomUUID()),
text,
completed: false,
timestamp: DateTime.makeUnsafe(new Date()),
})),
);
export const makeServices = (router = Router.BrowserRouter()) =>
Layer.mergeAll(CreateTodo, Todos.replicateToStorage).pipe(
Layer.provideMerge(Model),
Layer.provideMerge([Todos.local, router]),
);
export const Services = makeServices();
Changes from Assemble the application1 file
| Before | After | Change | Code |
|---|---|---|---|
| … 3 unchanged lines … | |||
| 4 | 4 | Unchanged | import * as App from "./application.js"; |
| 5 | 5 | Unchanged | import * as Domain from "./domain.js"; |
| 6 | 6 | Unchanged | |
| 7 | Removed | | |
| 7 | Added | const TODOS_STORAGE_KEY = `@typed/tutorial/todo-10`; | |
| 8 | 8 | Unchanged | |
| 9 | 9 | Unchanged | // Parsed JSON is not yet a TodoList; decode at the storage boundary. |
| 10 | 10 | Unchanged | const TodoListJson = Schema.fromJsonString(Schema.toCodecJson(Domain.TodoList)); |
| … 70 unchanged lines … | |||