
TL;DR
The TypeScript patterns that show up in every AI project. Streaming responses, type-safe tool definitions, structured output, retry logic, and more.
Read next
The AI SDK is the fastest way to add streaming AI responses to your Next.js app. Here is how to use it with Claude, GPT, and open source models.
5 min readA practical guide to building AI agents with TypeScript using the Vercel AI SDK. Tool use, multi-step reasoning, and real patterns you can ship today.
10 min readTransformers.js lets you run machine learning models in the browser with zero backend. Here is how to use it for text generation, speech recognition, image classification, and semantic search.
7 min readThese are the patterns I reach for in every AI project. Not theoretical - these show up in real TypeScript codebases that ship AI features.
Every AI response should stream. Users see output immediately instead of waiting for the full response.
For broader context, pair this with How to Build Full-Stack TypeScript Apps With AI in 2026 and The Next.js AI App Stack for 2026; those companion pieces show where this fits in the wider AI developer workflow.
async function* streamCompletion(prompt: string) {
const response = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield decoder.decode(value);
}
}
// Usage
for await (const chunk of streamCompletion("Explain TypeScript generics")) {
process.stdout.write(chunk);
}
The Vercel AI SDK wraps this into streamText() which handles the protocol automatically.
AI tools need runtime validation. Zod gives you TypeScript types and validation from a single schema.
import { z } from "zod";
import { tool } from "ai";
const weatherTool = tool({
description: "Get current weather for a location",
parameters: z.object({
city: z.string().describe("City name"),
units: z.enum(["celsius", "fahrenheit"]).default("celsius"),
}),
execute: async ({ city, units }) => {
const data = await fetchWeather(city, units);
return { temperature: data.temp, condition: data.condition };
},
});
The parameters schema validates input AND generates the JSON Schema that the model sees. One source of truth.
When you need the model to return a specific shape, not free text.
import { generateObject } from "ai";
import { z } from "zod";
const ProductReview = z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
score: z.number().min(0).max(10),
keyPoints: z.array(z.string()).max(5),
recommendation: z.boolean(),
});
type ProductReview = z.infer<typeof ProductReview>;
const { object } = await generateObject({
model: anthropic("claude-sonnet-4-6"),
schema: ProductReview,
prompt: `Analyze this review: "${reviewText}"`,
});
// object is fully typed as ProductReview
console.log(object.sentiment, object.score);
Every AI API call fails sometimes. Rate limits, timeouts, server errors. Wrap calls in retry logic.
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
const isRetryable =
error instanceof Error &&
(error.message.includes("429") ||
error.message.includes("503") ||
error.message.includes("timeout"));
if (!isRetryable) throw error;
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("Unreachable");
}
// Usage
const result = await withRetry(() =>
generateText({ model: anthropic("claude-sonnet-4-6"), prompt })
);
Get the weekly deep dive
Tutorials on Claude Code, AI agents, and dev tools - delivered free every week.
From the archive
Apr 2, 2026 • 14 min read
Apr 2, 2026 • 15 min read
Apr 2, 2026 • 12 min read
Apr 2, 2026 • 10 min read
When agents can take multiple action types, discriminated unions make the type system enforce correctness.
type AgentAction =
| { type: "search"; query: string }
| { type: "write_file"; path: string; content: string }
| { type: "run_command"; command: string; cwd?: string }
| { type: "ask_user"; question: string }
| { type: "done"; result: string };
function executeAction(action: AgentAction): Promise<string> {
switch (action.type) {
case "search":
return searchWeb(action.query);
case "write_file":
return writeFile(action.path, action.content);
case "run_command":
return exec(action.command, { cwd: action.cwd });
case "ask_user":
return prompt(action.question);
case "done":
return Promise.resolve(action.result);
}
}
TypeScript guarantees you handle every action type. Adding a new type without handling it is a compile error.
Type-safe conversation history that works across providers.
interface Message<Role extends string = string> {
role: Role;
content: string;
metadata?: Record<string, unknown>;
}
type ChatMessage = Message<"user" | "assistant" | "system">;
class Conversation {
private messages: ChatMessage[] = [];
system(content: string): this {
this.messages.push({ role: "system", content });
return this;
}
user(content: string): this {
this.messages.push({ role: "user", content });
return this;
}
assistant(content: string): this {
this.messages.push({ role: "assistant", content });
return this;
}
toArray(): ChatMessage[] {
return [...this.messages];
}
get lastAssistant(): string | undefined {
return this.messages.findLast((m) => m.role === "assistant")?.content;
}
}
Switch between AI providers without changing application code.
interface AIProvider {
generate(prompt: string, options?: GenerateOptions): Promise<string>;
stream(prompt: string, options?: GenerateOptions): AsyncIterable<string>;
}
interface GenerateOptions {
maxTokens?: number;
temperature?: number;
systemPrompt?: string;
}
function createProvider(name: "anthropic" | "openai"): AIProvider {
const providers: Record<string, AIProvider> = {
anthropic: {
generate: async (prompt, opts) => {
const { text } = await generateText({
model: anthropic("claude-sonnet-4-6"),
prompt,
maxTokens: opts?.maxTokens,
temperature: opts?.temperature,
system: opts?.systemPrompt,
});
return text;
},
stream: (prompt, opts) => streamProvider("anthropic", prompt, opts),
},
openai: {
generate: async (prompt, opts) => {
const { text } = await generateText({
model: openai("gpt-5"),
prompt,
maxTokens: opts?.maxTokens,
});
return text;
},
stream: (prompt, opts) => streamProvider("openai", prompt, opts),
},
};
return providers[name];
}
Track and limit token usage per request, per user, or per session.
interface TokenBudget {
maxInput: number;
maxOutput: number;
used: { input: number; output: number };
}
function createBudget(maxInput = 100_000, maxOutput = 4_096): TokenBudget {
return { maxInput, maxOutput, used: { input: 0, output: 0 } };
}
function checkBudget(budget: TokenBudget, inputTokens: number): boolean {
return budget.used.input + inputTokens <= budget.maxInput;
}
function recordUsage(
budget: TokenBudget,
input: number,
output: number
): TokenBudget {
return {
...budget,
used: {
input: budget.used.input + input,
output: budget.used.output + output,
},
};
}
// Usage in an agent loop
let budget = createBudget();
while (checkBudget(budget, estimatedTokens)) {
const result = await generateText({ model, prompt });
budget = recordUsage(budget, result.usage.promptTokens, result.usage.completionTokens);
}
Never use untyped process.env directly. Parse and validate at startup.
import { z } from "zod";
const envSchema = z.object({
ANTHROPIC_API_KEY: z.string().min(1),
OPENAI_API_KEY: z.string().min(1),
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
MAX_TOKENS: z.coerce.number().default(4096),
ENABLE_STREAMING: z.coerce.boolean().default(true),
});
export const env = envSchema.parse(process.env);
// Now fully typed
console.log(env.ANTHROPIC_API_KEY); // string
console.log(env.MAX_TOKENS); // number
console.log(env.ENABLE_STREAMING); // boolean
Parse once at the top of your app. If any variable is missing or malformed, it crashes immediately with a clear error instead of failing silently at runtime.
Replace try/catch with a Result type for composable error handling.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
async function safeGenerate(prompt: string): Promise<Result<string>> {
try {
const { text } = await generateText({
model: anthropic("claude-sonnet-4-6"),
prompt,
});
return ok(text);
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
// Usage - no try/catch needed
const result = await safeGenerate("Explain monads");
if (result.ok) {
console.log(result.value);
} else {
console.error("Failed:", result.error.message);
}
Streaming (pattern 1) and structured output (pattern 3) have the biggest impact. Streaming is table stakes for user experience. Structured output eliminates parsing errors and gives you type safety on model responses.
Zod. TypeScript types disappear at runtime, but AI tools need runtime validation. Zod schemas generate both the TypeScript type (via z.infer) and the JSON Schema that models consume. One schema, two outputs.
Use the retry with exponential backoff pattern (pattern 4). Check for 429 status codes, add jitter to prevent thundering herd, and set a max retry count. The Vercel AI SDK has built-in retry support.
Use generateObject() with a Zod schema (pattern 3). The response is fully typed at compile time and validated at runtime. For streaming, use streamObject() which gives you partial typed results as they arrive.
Use the provider abstraction pattern (pattern 7) or the Vercel AI SDK which handles this natively. Define a common interface and swap the model string. The AI SDK supports Anthropic, OpenAI, Google, and 20+ other providers with the same API.
Technical content at the intersection of AI and development. Building with AI agents, Claude Code, and modern dev tools - then showing you exactly how it works.
The TypeScript toolkit for building AI apps. Unified API across OpenAI, Anthropic, Google. Streaming, tool calling, stru...
View ToolTypeScript-first AI agent framework. Workflows, RAG, tool use, evals, and integrations. Built for production Node.js app...
View ToolLLM data framework for connecting custom data sources to language models. Best-in-class RAG, data connectors, and query...
View ToolStructured data extraction from any LLM using Pydantic models. Automatic retries, validation, and streaming. 3M+ monthly...
View ToolKnow what each agent run cost before the bill arrives. Budgets and alerts included.
View AppTurn product knowledge into browser QA plans, executable checklists, and release reports.
View AppEvery coding agent in one window. Stop alt-tabbing between Claude, Codex, and Cursor.
View AppStep-by-step guide to building an MCP server in TypeScript - from project setup to tool definitions, resource handling, testing, and deployment.
AI AgentsGranular allow/ask/deny rules per tool with wildcard patterns.
Claude CodeInstall the dd CLI and scaffold your first AI-powered app in under a minute.
Getting Started
The AI SDK is the fastest way to add streaming AI responses to your Next.js app. Here is how to use it with Claude, GPT,...

A practical guide to building AI agents with TypeScript using the Vercel AI SDK. Tool use, multi-step reasoning, and rea...

Transformers.js lets you run machine learning models in the browser with zero backend. Here is how to use it for text ge...

How RAG works, why it matters, and how to implement it in TypeScript. The technique that lets AI models use your data wi...

MCP lets AI agents connect to databases, APIs, and tools. Here is what it is and how to use it in your TypeScript projec...

Convex and Supabase both work for AI-powered apps. Here is when to use each, based on building production apps with both...

New tutorials, open-source projects, and deep dives on coding agents - delivered weekly.