Skip to content

Worked review 1: a fetch-and-render service

This is the first of four full reviews. Each one gives you a complete, AI-style program, asks you to find the planted bugs using the last four lessons’ habits, then shows the real tools’ real output and a fixed version that actually compiles and runs correctly. This one is a small service that fetches a user record and renders a welcome message — a boundary, a floating promise, a non-null assertion, and a default value, all in about 35 lines.

Review this before reading further

// @run
interface User { id: number; name: string; nickname?: string }
async function fetchUserJson(id: number): Promise<string> {
// stands in for a real fetch(`/api/users/${id}`).then(r => r.text())
return id === 1
? '{"id": 1, "name": "Ada"}'
: '{"id": 2, "name": "Grace", "nickname": "Amazing Grace"}';
}
async function fetchUser(id: number): Promise<User> {
const raw = await fetchUserJson(id);
return JSON.parse(raw) as User; // BUG 1: as -- shape never validated
}
async function logAccessAsync(id: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 5));
console.log(`access logged for user ${id}`);
}
async function renderProfile(id: number): Promise<string> {
logAccessAsync(id); // BUG 2: floating promise -- missing await
const user = await fetchUser(id);
const displayName = user.nickname!.toUpperCase(); // BUG 3: non-null assertion on optional field
return `Welcome, ${displayName}`;
}
interface RenderOptions { retries?: number }
function getRetryCount(options: RenderOptions): number {
return options.retries || 3; // BUG 4: || instead of ?? -- explicit 0 silently becomes 3
}
async function main(): Promise<void> {
console.log("retries for explicit 0:", getRetryCount({ retries: 0 }));
console.log(await renderProfile(2));
console.log(await renderProfile(1)); // user 1 has no nickname -- crashes
}
main();
Open in TS Playground

Click Check types: tsc --strict reports zero errors. Click Run: it prints two lines, then crashes.

Real output:

retries for explicit 0: 3
Welcome, AMAZING GRACE
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
at renderProfile (...)

Notice what is missing from that output entirely: access logged for user 2 never prints. logAccessAsync(2)’s 5ms timer never gets the chance to fire, because the process crashes first — the exact danger a floating promise creates: its side effect can simply vanish, with nothing to show it was ever supposed to happen.

Four planted bugs, one boundary, one async, two nullability. Try to name all four — with a line number and what actually goes wrong — before opening the answers below.

Bug 1 — line 12, JSON.parse(raw) as User

Symptom: fetchUser compiles as if it always returns a real User, but nothing checks that the parsed JSON actually has an id and a name.

Evidence: it happens to work for both ids in this program only because both fake payloads are well-formed — the moment the boundary function returns something else (a 404 HTML page, an error object, a partial record from a flaky backend), as User still compiles and still hands the caller a value it will treat as trustworthy.

Fix: a real type guard, checked against the parsed value before it is trusted:

interface User { id: number; name: string; nickname?: string }
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return typeof v.id === "number" && typeof v.name === "string";
}
Bug 2 — line 21, logAccessAsync(id); with no await

Symptom: the access-log side effect is fired and immediately forgotten; renderProfile moves on to the next line without waiting for it.

Evidence: the real output above proves it directly — access logged for user 2 never printed at all, because the process crashed on the very next call to renderProfile before the 5ms timer had a chance to fire. In a longer-running server this would just as easily lose the log silently with no crash at all, just an unlucky ordering.

Fix: await it.

// @skip-verify partial snippet, illustrates the fix only -- see the full fixed version below
async function renderProfile(id: number): Promise<string> {
await logAccessAsync(id);
// ...
}
Bug 3 — line 23, user.nickname!.toUpperCase()

Symptom: nickname is typed string | undefined for a reason — not every user has one — and ! tells the checker to ignore that entirely.

Evidence: the real crash. User 1’s payload has no nickname; user.nickname! is undefined at runtime, and .toUpperCase() throws TypeError: Cannot read properties of undefined (reading 'toUpperCase').

Fix: fall back to something that is always present, like name:

// @skip-verify partial snippet, illustrates the fix only -- see the full fixed version below
const displayName = (user.nickname ?? user.name).toUpperCase();
Bug 4 — line 30, options.retries || 3

Symptom: a caller who explicitly passes retries: 0 (meaning “do not retry at all”) silently gets 3 instead.

Evidence: the first line of real output above — retries for explicit 0: 3, not 0.

Fix: ?? only falls through on null/undefined, not on every falsy value:

// @skip-verify partial snippet, illustrates the fix only -- see the full fixed version below
function getRetryCount(options: RenderOptions): number {
return options.retries ?? 3;
}
// @run
interface User { id: number; name: string; nickname?: string }
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return typeof v.id === "number" && typeof v.name === "string";
}
async function fetchUserJson(id: number): Promise<string> {
return id === 1
? '{"id": 1, "name": "Ada"}'
: '{"id": 2, "name": "Grace", "nickname": "Amazing Grace"}';
}
async function fetchUser(id: number): Promise<User> {
const raw = await fetchUserJson(id);
const parsed: unknown = JSON.parse(raw);
if (!isUser(parsed)) throw new Error(`invalid user payload for id ${id}`);
return parsed;
}
async function logAccessAsync(id: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 5));
console.log(`access logged for user ${id}`);
}
async function renderProfile(id: number): Promise<string> {
await logAccessAsync(id);
const user = await fetchUser(id);
const displayName = (user.nickname ?? user.name).toUpperCase();
return `Welcome, ${displayName}`;
}
interface RenderOptions { retries?: number }
function getRetryCount(options: RenderOptions): number {
return options.retries ?? 3;
}
async function main(): Promise<void> {
console.log("retries for explicit 0:", getRetryCount({ retries: 0 }));
console.log(await renderProfile(2));
console.log(await renderProfile(1));
}
main();

Real output:

retries for explicit 0: 0
access logged for user 2
Welcome, AMAZING GRACE
access logged for user 1
Welcome, ADA

Every log line now appears, in the right order, and user 1 — who has no nickname — falls back to their name instead of crashing.

tsc --strict on the buggy version: zero errors, on both the buggy and fixed versions — this entire review is invisible to the compiler alone.

typescript-eslint’s typed rules, run on the buggy version:

21:3 error Promises must be awaited, end with a call to .catch, end with a call to .then
with a rejection handler or be explicitly marked as ignored with the `void`
operator @typescript-eslint/no-floating-promises
23:23 error Forbidden non-null assertion @typescript-eslint/no-non-null-assertion
30:26 error Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`),
as it is a safer operator @typescript-eslint/prefer-nullish-coalescing
✖ 3 problems (3 errors, 0 warnings)

Three of the four bugs, caught directly and specifically. Bug 1 — the as User boundary cast — is the one no tool in this course’s ladder catches; it took actually reading the boundary, and running the code, to find it.

The buggy version of this program compiles with zero tsc --strict errors. Which single planted bug is the only one no tool in this lesson's ladder (tsc or typed eslint) catches?
In the real output, 'access logged for user 2' never printed at all, even though logAccessAsync(2) was called. Why?
Why does getRetryCount({ retries: 0 }) return 3 in the buggy version instead of 0?
The fixed version replaces JSON.parse(raw) as User with a call to a function isUser. What does that change actually add that the assertion didn't have?