Sanity
Why Type Safety Matters in AI-Powered Next.js Applications
Type safety Next.js AI integrations prevent silent bugs and runtime failures. Learn how TypeScript, Zod, and Sanity Typegen create a bulletproof full-stack type system.

Modern web applications are increasingly powered by AI — streaming LLM responses, vector search, and dynamic content generation are becoming table stakes. But as AI integrations grow more complex, so does the risk of silent failures, shape mismatches, and runtime crashes. Type safety Next.js AI development is no longer a nice-to-have; it is the foundation that keeps production systems reliable, maintainable, and refactor-friendly. This post walks through every layer of the stack — from LLM API responses to Sanity CMS queries to React Server Components — and shows you exactly how to lock it all down with TypeScript.
The Problem with Untyped AI Integrations
When you call an AI API without types, you are trusting a black box to return exactly what you expect — every single time. In practice, that trust is routinely broken. Consider a typical untyped fetch to an LLM endpoint:
// ❌ Untyped — a maintenance nightmare
const res = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ prompt }) });
const data = await res.json(); // data is `any`
console.log(data.choices[0].message.content); // Runtime error if shape changes
The problems compound quickly across a production codebase:
- Silent shape changes. AI providers update their response schemas. Without types, your app silently breaks at runtime instead of failing loudly at compile time.
- Impossible refactoring. Renaming a field in an untyped response requires a manual grep across the entire codebase — and you will miss something.
- No IDE support. Autocomplete, go-to-definition, and inline documentation all disappear when everything is
any. - Runtime crashes in production. A missing
choicesarray or a renamedcontentfield causes aTypeErrorthat only surfaces when a real user hits the endpoint. - Untestable edge cases. Without a defined shape, writing unit tests that cover all possible response variants is guesswork.
How TypeScript Prevents Runtime Failures in AI Apps
TypeScript's value in AI-powered applications goes far beyond catching typos. It provides three layers of protection:
- Static analysis at compile time. TypeScript evaluates your code before it ever runs. If you access a property that does not exist on a type, the build fails — not the production server.
- Structural typing for API contracts. You can define the exact shape of an AI API response as a TypeScript interface and enforce it at every call site.
- IDE-driven development. With accurate types, your editor provides autocomplete, inline docs, and instant error highlighting — dramatically reducing the feedback loop.
Here is a direct comparison of typed vs. untyped AI response handling:
// ❌ Untyped handler
async function getCompletion(prompt: string) {
const res = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
});
return res.choices[0].message.content; // `res` is `any` — no safety net
}
// ✅ Typed handler
import OpenAI from 'openai';
const openai = new OpenAI();
async function getCompletion(prompt: string): Promise<string> {
const res = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
});
// TypeScript knows `content` is `string | null`
const content = res.choices[0].message.content;
if (!content) throw new Error('Empty response from model');
return content;
}
The typed version forces you to handle the null case — a real edge case that the OpenAI SDK exposes — before it can reach production.
Typing LLM API Responses
Most major AI SDKs ship with TypeScript definitions, but you often need to define your own types for structured outputs, tool calls, or custom response schemas.
Using Plain TypeScript Interfaces
// types/ai.ts
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface ChatCompletionChoice {
index: number;
message: ChatMessage;
finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | null;
}
export interface ChatCompletionResponse {
id: string;
object: 'chat.completion';
created: number;
model: string;
choices: ChatCompletionChoice[];
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
Runtime Validation with Zod
TypeScript types are erased at runtime. For data crossing a network boundary, you need runtime validation too. Zod is the gold standard:
import { z } from 'zod';
const ChatMessageSchema = z.object({
role: z.enum(['system', 'user', 'assistant']),
content: z.string(),
});
const ChatCompletionResponseSchema = z.object({
id: z.string(),
object: z.literal('chat.completion'),
created: z.number(),
model: z.string(),
choices: z.array(
z.object({
index: z.number(),
message: ChatMessageSchema,
finish_reason: z
.enum(['stop', 'length', 'tool_calls', 'content_filter'])
.nullable(),
})
),
usage: z.object({
prompt_tokens: z.number(),
completion_tokens: z.number(),
total_tokens: z.number(),
}),
});
// Infer the TypeScript type — single source of truth
export type ChatCompletionResponse = z.infer<typeof ChatCompletionResponseSchema>;
export async function fetchCompletion(prompt: string): Promise<ChatCompletionResponse> {
const raw = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt }),
}).then((r) => r.json());
// Throws a ZodError with a detailed message if the shape is wrong
return ChatCompletionResponseSchema.parse(raw);
}
With Zod, your TypeScript type and your runtime validator are derived from the same schema definition — eliminating the drift that occurs when you maintain them separately.
Typed Structured Outputs
OpenAI's structured outputs feature lets you enforce a JSON schema on the model's response. Combine it with Zod for end-to-end safety:
import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod';
const ArticleSummarySchema = z.object({
title: z.string(),
keyPoints: z.array(z.string()).min(3).max(7),
sentiment: z.enum(['positive', 'neutral', 'negative']),
});
const res = await openai.beta.chat.completions.parse({
model: 'gpt-4o-2024-08-06',
messages: [{ role: 'user', content: `Summarize: ${articleText}` }],
response_format: zodResponseFormat(ArticleSummarySchema, 'article_summary'),
});
const summary = res.choices[0].message.parsed; // Fully typed as ArticleSummary
Type-Safe GROQ Queries with Sanity Typegen
Sanity's content lake is queried with GROQ — a powerful query language that returns JSON. Without types, every CMS fetch is an any that can silently break when editors rename fields or restructure documents. Sanity Typegen solves this by generating TypeScript types directly from your GROQ queries and schema definitions.
Setting Up Typegen
# Install the Sanity CLI
npm install --save-dev sanity@latest
# Generate types from your schema and queries
npx sanity typegen generate
This produces a sanity.types.ts file containing types for every document type in your schema, plus inferred return types for every tagged GROQ query.
Writing Type-Safe GROQ Queries
// lib/queries.ts
import { defineQuery } from 'next-sanity';
// Tag your query with defineQuery — Typegen picks this up
export const postBySlugQuery = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
publishedAt,
excerpt,
"author": author->{ name, image },
body
}
`);
// app/blog/[slug]/page.tsx
import { client } from '@/lib/sanity';
import { postBySlugQuery } from '@/lib/queries';
import type { PostBySlugQueryResult } from '@/sanity.types'; // Auto-generated!
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post: PostBySlugQueryResult = await client.fetch(postBySlugQuery, {
slug: params.slug,
});
if (!post) return <div>Post not found</div>;
// TypeScript knows `post.title` is `string`, `post.author.name` is `string`, etc.
return <article><h1>{post.title}</h1></article>;
}
Typegen eliminates an entire class of bugs: the "I renamed a field in Sanity Studio and forgot to update the frontend" class. The build now fails instead of the page.
End-to-End Type Safety from CMS to UI
The real power of TypeScript in a Next.js + Sanity stack is the ability to trace a type from its origin in the CMS schema all the way to a leaf prop in a client component — with zero any in between.
// 1. Typegen generates the TypeScript type (sanity.types.ts — auto-generated)
export type Post = {
_id: string;
_type: 'post';
title: string;
slug: { current: string };
body: PortableTextBlock[];
author: { name: string; image: SanityImageSource };
};
// 2. Server Component fetches and passes typed data (app/blog/[slug]/page.tsx)
import type { Post } from '@/sanity.types';
async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await sanityClient.fetch<Post>(postBySlugQuery, { slug: params.slug });
return <BlogPostContent post={post} />;
}
// 3. Client Component receives fully typed props (components/BlogPostContent.tsx)
'use client';
import type { Post } from '@/sanity.types';
interface BlogPostContentProps {
post: Post;
}
export function BlogPostContent({ post }: BlogPostContentProps) {
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
</article>
);
}
This chain means a schema change in Sanity Studio triggers a Typegen regeneration, which surfaces a TypeScript compile error, which the developer fixes before deployment — never in production.
Practical TypeScript Patterns
Beyond basic interfaces, several advanced TypeScript patterns are especially valuable in AI-powered CMS applications.
Discriminated Unions for AI Tool Calls
type ToolCall =
| { type: 'search'; query: string; maxResults: number }
| { type: 'summarize'; documentId: string; length: 'short' | 'medium' | 'long' }
| { type: 'translate'; text: string; targetLanguage: string };
function handleToolCall(call: ToolCall): Promise<string> {
switch (call.type) {
case 'search':
// TypeScript narrows: `call.query` and `call.maxResults` are available
return searchDocuments(call.query, call.maxResults);
case 'summarize':
return summarizeDocument(call.documentId, call.length);
case 'translate':
return translateText(call.text, call.targetLanguage);
}
}
Branded Types for IDs
Prevent accidentally passing a postId where a userId is expected:
type Brand<T, B extends string> = T & { readonly __brand: B };
type PostId = Brand<string, 'PostId'>;
type UserId = Brand<string, 'UserId'>;
function getPost(id: PostId): Promise<Post> { /* ... */ }
function getUser(id: UserId): Promise<User> { /* ... */ }
// ✅ Correct
getPost('post-123' as PostId);
// ❌ TypeScript error — cannot pass UserId where PostId is expected
const userId = 'user-456' as UserId;
getPost(userId); // Error!
Utility Types for Partial Updates
type Post = {
_id: PostId;
title: string;
body: PortableTextBlock[];
publishedAt: string;
featured: boolean;
};
// For PATCH operations — all fields optional except _id
type PostUpdate = Pick<Post, '_id'> & Partial<Omit<Post, '_id'>>;
// For creation — _id is generated server-side
type PostCreate = Omit<Post, '_id'>;
// For list views — only metadata, no body
type PostSummary = Pick<Post, '_id' | 'title' | 'publishedAt' | 'featured'>;
The satisfies Operator
The satisfies operator (TypeScript 4.9+) validates a value against a type without widening it:
const modelConfig = {
gpt4o: { maxTokens: 128_000, supportsVision: true },
claude3: { maxTokens: 200_000, supportsVision: true },
mistral: { maxTokens: 32_000, supportsVision: false },
} satisfies Record<string, { maxTokens: number; supportsVision: boolean }>;
// TypeScript still knows the exact keys — no widening to `string`
modelConfig.gpt4o.maxTokens; // ✅ number
modelConfig.unknown; // ❌ TypeScript error
Common Mistakes
Even experienced TypeScript developers make these mistakes when building AI-powered Next.js applications:
- Using any at API boundaries. Writing
const data: any = await res.json()defeats the entire type system. Useunknownand narrow with Zod or type guards instead. - Skipping runtime validation. TypeScript types are compile-time only. An AI API can return anything at runtime. Always validate external data with Zod or a similar runtime validator before trusting its shape.
- Disabling strict mode. Adding
"strict": falsetotsconfig.jsondisablesstrictNullChecks,noImplicitAny, and other critical checks. Always keep"strict": true. - Type-casting with as instead of narrowing. Writing
const post = data as Postbypasses type checking entirely. Use Zod's.parse()or write proper type guards that actually verify the shape at runtime. - Not typing environment variables. Accessing
process.env.OPENAI_API_KEYreturnsstring | undefined. Validate and type your env vars at startup using a library liket3-envor a Zod schema. - Stale generated types. Running
sanity typegen generateonce and never again means your types drift from your schema. Add typegen to your CI pipeline and pre-commit hooks.
Best Practices
- Enable TypeScript strict mode from day one. Set
"strict": trueintsconfig.jsonand never disable it. The short-term pain of fixing strict errors pays dividends for the lifetime of the project. - Validate all external data at the boundary. Use Zod schemas for every AI API response, webhook payload, and CMS query result. Treat anything crossing a network boundary as
unknownuntil validated. - Use defineQuery and run Sanity Typegen in CI. Tag every GROQ query with
defineQuery, runsanity typegen generateas part of your build, and commit the generatedsanity.types.tsfile. - Derive TypeScript types from Zod schemas. Use
z.infer<typeof MySchema>instead of maintaining separate interface definitions. One source of truth eliminates drift. - Never use as for type assertions on external data. Reserve
asfor cases where you have already validated the shape. For unvalidated data, use Zod's.parse()or.safeParse(). - Type your environment variables. Use
t3-envor a startup Zod validation to ensure all required API keys and config values are present and correctly typed before your app boots. - Write typed API route handlers. In Next.js App Router, type your
RequestandResponseobjects explicitly. Use a wrapper likenext-safe-actionorzsafor Server Actions to get typed inputs and outputs automatically.
FAQ
Do I need Zod if I'm already using TypeScript?
Yes. TypeScript types are erased at runtime — they provide zero protection against malformed data from an AI API or CMS. Zod validates the actual shape of data at runtime and throws a descriptive error if it does not match. Use both: TypeScript for compile-time safety, Zod for runtime safety.
Does the OpenAI Node SDK provide TypeScript types?
Yes, the official openai npm package ships with comprehensive TypeScript definitions. However, for structured outputs and custom tool schemas, you should still define Zod schemas and use zodResponseFormat to get both runtime validation and TypeScript inference from a single definition.
How often should I regenerate Sanity types?
Every time your Sanity schema changes. The safest approach is to run sanity typegen generate as a pre-build step in your CI/CD pipeline. You can also add it as a postinstall script or a pre-commit hook to catch schema drift early.
What is the performance overhead of Zod validation?
For typical AI API responses and CMS query results, Zod validation adds microseconds — negligible compared to network latency. For extremely high-throughput scenarios (thousands of validations per second), consider valibot as a lighter alternative with a similar API.
Can I use TypeScript strict mode in an existing Next.js project?
Yes, but incrementally. Set "strict": true and use // @ts-expect-error to suppress errors in files you have not migrated yet. Tackle files one by one, starting with your most critical data-fetching and AI integration code. Most teams complete the migration in a few sprints.
Conclusion
Building AI-powered Next.js applications without type safety is like deploying to production without tests — it works until it does not, and when it breaks, it breaks in the worst possible way. By combining TypeScript strict mode, Zod runtime validation, Sanity Typegen, and disciplined use of utility types and discriminated unions, you create a system where errors surface at compile time, not in front of users.
The investment is front-loaded: setting up schemas, running typegen, and enforcing strict mode takes effort. But the payoff — fearless refactoring, self-documenting code, and dramatically fewer production incidents — compounds over the entire life of your project.
Start today: enable "strict": true, add Zod to your AI API boundaries, and run sanity typegen generate. Your future self will thank you.


