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.
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.
| Feature | Why it matters for AI apps |
|---|---|
async / await, Promise.all | Every 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 literals | Building prompts from parts |
Async iterators, for await | Streaming responses are async iterables |
AbortController, AbortSignal.timeout() | Cancelling slow requests and stopping a stream when the user leaves |
| TypeScript unions and narrowing | Model responses are lists of different block types |
unknown + runtime validation (Zod) | Network data and model output have no compile-time type |
Generics, satisfies, utility types | Typed 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.
Hover or tap a component to see what it does.
Show text descriptionHide 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.
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 zodpnpm add zodimport { 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
Show text descriptionHide 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.
| Span | Starts at | Duration |
|---|---|---|
| Sequential: getUser | 0 ms | 300 ms |
| Sequential: getOrders | 300 ms | 450 ms |
| Sequential: getPlan | 750 ms | 250 ms |
| Parallel: getUser | 0 ms | 300 ms |
| Parallel: getOrders | 0 ms | 450 ms |
| Parallel: getPlan | 0 ms | 250 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().
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 downThe 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.
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:
satisfieschecks a value against a type without widening it, soconst tools = [...] satisfies Tool[]keeps the literal tool names for autocompletion.- Generics keep helpers precise:
function first<T>(items: T[]): T | undefinedreturns the right type for any array. - Utility types such as
Pick,Omit,Partial, andRecordderive new types from existing ones instead of duplicating them. unknownoverany.anysilently turns off type checking for everything it touches;unknownforces you to check before use, which is exactly what you want at a boundary.
Common mistakes#
- Using
anyfor API responses. It compiles today and crashes in production when a field is missing. Useunknownand validate. - Trusting a type assertion.
JSON.parse(body) as Ordertells 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-promiseslint rule. - Awaiting inside a loop by accident.
for (const id of ids) await fetchOne(id)is sequential. UsePromise.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
- Write a Zod schema for a support ticket summary:
severity("low" | "medium" | "high"),summary(1–280 characters), andaffected_users(a non-negative integer). - Validate three inputs: a correct one, one with
severity: "urgent", and one whereaffected_usersis the string"12". - 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
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
switchand an exhaustivenevercheck. - Start independent async work together with
Promise.all, add deadlines withAbortSignal.timeout(), and keep partial results withPromise.allSettled. - Streams are async iterables consumed with
for await.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Node.js Deep Dive: Event Loop, Async I/O, Streams, and ESMHow Node.js runs your code and why it suits AI apps: the event loop, microtasks, blocking, streams with backpressure, and ES modules, with runnable examples.
- Structured Outputs with Zod: Typed JSON from LLMs in TypeScriptGet typed, schema-valid JSON from Claude with Zod and messages.parse, validate business rules in code, retry with feedback, and use strict tools for tool inputs.