Skip to content
AI Full-Stack Wiki

Modern JavaScript and TypeScript for AI Engineers

The JavaScript and TypeScript you need for AI apps: async/await, Promise.all, discriminated unions, Zod validation, and async iterators, with runnable code.

Difficulty: Beginner16 min readUpdated
On this page· The subset that matters

Why this matters

Every layer of an AI web app is JavaScript or TypeScript: the React UI, the API routes, the workers, and the SDK you call models with. Three language features carry most of the weight. Async code lets you call slow APIs without blocking. Discriminated unions are the shape of every model response. Runtime validation is how you trust data you did not create, including what the model returns. Master these and the rest of the wiki reads easily.

The subset that matters#

You do not need all of JavaScript. This table is the working set for this wiki, roughly in order of how often you will use it.

FeatureWhy it matters for AI apps
async / await, Promise.allEvery model, database, and HTTP call is asynchronous
ES modules (import / export)The modern module system; required for top-level await
Destructuring, spread, optional chaining ?., ??Working with nested API responses concisely and safely
Template literalsBuilding prompts from parts
Async iterators, for awaitStreaming responses are async iterables
AbortController, AbortSignal.timeout()Cancelling slow requests and stopping a stream when the user leaves
TypeScript unions and narrowingModel responses are lists of different block types
unknown + runtime validation (Zod)Network data and model output have no compile-time type
Generics, satisfies, utility typesTyped helpers without losing precision

Types describe code; schemas guard data#

TypeScript checks the code you write. It cannot check data that only exists at runtime: a request body, a row from an external API, or JSON produced by a model. At the boundary of your program that data has the type unknown, and something must check it before the rest of your code relies on it.

Validating untrusted input at the boundaryAn HTTP request body arrives as unknown data, is parsed and validated with a Zod schema, and becomes either a typed value for business logic or a 400 error.bodyClientrequest.json()type: unknownSchema.safeParseruntime validation400 Bad Requestwith issuesTyped valuez.infer<typeof Schema>Business logicDB, model calls

Hover or tap a component to see what it does.

TypeScript types disappear at runtime, so untrusted input is validated with a Zod schema at the boundary and only then becomes a typed value.
Show text description

Data flows from left to right. TypeScript only checks code you write; data that arrives over the network has no compile-time type, so a runtime schema decides whether it may enter your program.

  • Client: Anything outside your process: browsers, other services, webhooks, model outputs. Treat its data as unknown.
  • request.json(): Parsing JSON gives you a value of unknown shape. TypeScript cannot check data that only exists at runtime.
  • Schema.safeParse: A Zod schema checks the real value at runtime and returns either typed data or a list of issues.
  • 400 Bad Request: Invalid input stops here, before it reaches your database or a model call.
  • Typed value: From here on, TypeScript knows the exact shape, so the compiler catches mistakes in the rest of the handler.
  • Business logic: Code that can trust its inputs is shorter and safer.

Discriminated unions: the shape of model responses#

A model response is not a string. It is a list of content blocks, each with a type field: text, a tool call, reasoning, and so on. TypeScript models this with a discriminated union, and switch on the discriminant narrows each case to its exact shape.

content-blocks.ts
type TextBlock = { type: "text"; text: string };
type ToolUseBlock = { type: "tool_use"; id: string; name: string; input: unknown };
type ThinkingBlock = { type: "thinking"; thinking: string };
type ContentBlock = TextBlock | ToolUseBlock | ThinkingBlock;
 
function describe(block: ContentBlock): string {
  switch (block.type) {
    case "text":
      return `Text: ${block.text.slice(0, 40)}`; // block is TextBlock here
    case "tool_use":
      return `Tool call: ${block.name}`; // block is ToolUseBlock here
    case "thinking":
      return "Reasoning block";
    default: {
      // If a new block type is added to the union, this line stops compiling.
      const unreachable: never = block;
      return unreachable;
    }
  }
}
 
const response: ContentBlock[] = [
  { type: "thinking", thinking: "" },
  { type: "text", text: "Let me look up that order." },
  { type: "tool_use", id: "toolu_1", name: "get_order_status", input: { order_id: "4821" } },
];
 
console.log(response.map(describe));

The never check in default is worth the three lines: when the API adds a new block type and you update the union, the compiler points to every switch that forgot to handle it. In real code you use the SDK's own types (Anthropic.ContentBlock), which are built exactly like this.

Runtime validation with Zod#

Zod schemas validate data at runtime and give you the matching TypeScript type for free with z.infer. Use safeParse at every boundary, and return a useful error instead of throwing deep inside your logic.

npm install zod
pnpm add zod
validate.ts
import { z } from "zod";
 
export const CreateNote = z.object({
  title: z.string().trim().min(1).max(120),
  body: z.string().max(10_000),
  tags: z.array(z.string()).max(10).default([]),
});
export type CreateNote = z.infer<typeof CreateNote>;
 
type Result<T> = { ok: true; value: T } | { ok: false; errors: string[] };
 
export function parseCreateNote(input: unknown): Result<CreateNote> {
  const result = CreateNote.safeParse(input);
  if (!result.success) {
    return {
      ok: false,
      errors: result.error.issues.map((i) => `${i.path.map(String).join(".") || "(root)"}: ${i.message}`),
    };
  }
  return { ok: true, value: result.data };
}
 
console.log(parseCreateNote(JSON.parse('{"title":"  Ship the RAG demo  ","body":"Friday"}')));
// { ok: true, value: { title: "Ship the RAG demo", body: "Friday", tags: [] } }
 
console.log(parseCreateNote({ title: "", body: 42 }));
// { ok: false, errors: ["title: Too small: ...", "body: Invalid input: expected string, received number"] }

Notice that the schema also transforms: trim() cleans the title and default([]) fills in missing tags. The same pattern validates model output later in the wiki, where it is even more important: a model can return well-formed JSON with a wrong value.

Async code without the traps#

Every model call takes seconds. How you combine those calls decides whether a page loads in one second or five.

  • Sequential await
  • Promise.all
Sequential awaits add up latencies; Promise.all runs independent requests concurrently, so total time is roughly the slowest request.
Show text description

Three independent requests take 300 ms, 450 ms, and 250 ms. Awaited one after another they finish at 1,000 ms; started together with Promise.all they finish at about 450 ms.

Request timings
SpanStarts atDuration
Sequential: getUser0 ms300 ms
Sequential: getOrders300 ms450 ms
Sequential: getPlan750 ms250 ms
Parallel: getUser0 ms300 ms
Parallel: getOrders0 ms450 ms
Parallel: getPlan0 ms250 ms

When calls do not depend on each other, start them together with Promise.all. When some may fail and you still want the others, use Promise.allSettled. When a call might hang, give it a deadline with AbortSignal.timeout().

concurrency.ts
const sleep = (ms: number, signal?: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal?.addEventListener(
      "abort",
      () => {
        clearTimeout(timer);
        reject(signal.reason);
      },
      { once: true },
    );
  });
 
// Stand-ins for real database and API calls.
async function getUser() {
  await sleep(300);
  return { id: "u_1", name: "Ada" };
}
async function getOrders() {
  await sleep(450);
  return [{ id: "o_1" }, { id: "o_2" }];
}
async function getPlan() {
  await sleep(250);
  return "pro";
}
 
let start = performance.now();
const user = await getUser();
const orders = await getOrders();
const plan = await getPlan();
console.log(`sequential: ${Math.round(performance.now() - start)} ms`, user.name, orders.length, plan);
 
start = performance.now();
const [user2, orders2, plan2] = await Promise.all([getUser(), getOrders(), getPlan()]);
console.log(`parallel: ${Math.round(performance.now() - start)} ms`, user2.name, orders2.length, plan2);
 
// A deadline: give up on a slow operation after 200 ms.
try {
  await sleep(1_000, AbortSignal.timeout(200));
} catch (error) {
  console.log("timed out:", (error as Error).name); // "TimeoutError"
}
 
// Partial failure: keep the results that succeeded.
const settled = await Promise.allSettled([getPlan(), Promise.reject(new Error("search is down"))]);
for (const r of settled) console.log(r.status === "fulfilled" ? `ok: ${r.value}` : `failed: ${(r.reason as Error).message}`);
node concurrency.ts
# sequential: 1003 ms Ada 2 pro
# parallel: 452 ms Ada 2 pro
# timed out: TimeoutError
# ok: pro
# failed: search is down

The same signal option works on fetch and on the Anthropic SDK, so a single AbortController can cancel a whole chain of work when a user closes the tab.

Async iterators: the shape of a stream#

A streamed model response arrives piece by piece. JavaScript represents "values that arrive over time" as an async iterable, consumed with for await. An async function* (async generator) is the simplest way to make one.

tokens.ts
async function* fakeTokenStream(text: string, delayMs = 60) {
  for (const word of text.split(" ")) {
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    yield `${word} `;
  }
}
 
let received = "";
for await (const token of fakeTokenStream("Streaming is just an async iterable of small chunks.")) {
  process.stdout.write(token);
  received += token;
}
console.log(`\n${received.trim().split(" ").length} chunks`);

When you meet for await (const event of stream) with the Anthropic SDK, it is exactly this pattern with real network chunks.

TypeScript features you will use daily#

A few more features turn up constantly in AI code:

  • satisfies checks a value against a type without widening it, so const tools = [...] satisfies Tool[] keeps the literal tool names for autocompletion.
  • Generics keep helpers precise: function first<T>(items: T[]): T | undefined returns the right type for any array.
  • Utility types such as Pick, Omit, Partial, and Record derive new types from existing ones instead of duplicating them.
  • unknown over any. any silently turns off type checking for everything it touches; unknown forces you to check before use, which is exactly what you want at a boundary.

Common mistakes#

  • Using any for API responses. It compiles today and crashes in production when a field is missing. Use unknown and validate.
  • Trusting a type assertion. JSON.parse(body) as Order tells the compiler a lie; it checks nothing. Parse with a schema instead.
  • Forgetting await. A floating promise swallows errors and returns before the work finishes. Enable the @typescript-eslint/no-floating-promises lint rule.
  • Awaiting inside a loop by accident. for (const id of ids) await fetchOne(id) is sequential. Use Promise.all(ids.map(fetchOne)) when the calls are independent, and limit concurrency for large lists.
  • Mixing CommonJS and ES modules. Pick ES modules ("type": "module") for new projects; it is what the examples in this wiki assume.

Try it

Validate a model-shaped payload

  1. Write a Zod schema for a support ticket summary: severity ("low" | "medium" | "high"), summary (1–280 characters), and affected_users (a non-negative integer).
  2. Validate three inputs: a correct one, one with severity: "urgent", and one where affected_users is the string "12".
  3. Change the schema so the string "12" is accepted and converted to a number (hint: z.coerce.number()), and decide whether that is a good idea for model output.

Check your understanding

Why can't TypeScript alone guarantee the shape of a request body?
Three independent calls take 300, 450, and 250 ms. About how long does Promise.all take?
What does the `never` assignment in a switch's default case give you?

Key takeaways

  • Types check your code; schemas check your data. Validate everything that crosses a boundary, including model output.
  • Model responses are discriminated unions of content blocks; narrow with switch and an exhaustive never check.
  • Start independent async work together with Promise.all, add deadlines with AbortSignal.timeout(), and keep partial results with Promise.allSettled.
  • Streams are async iterables consumed with for await.

Next steps#

Finished this article? Track it to see your progress on the roadmap.