Skip to content

Using TypeScript as a Contract Layer for LLM Code Output

TypeScript interfaces are machine-readable contracts for AI. Learn the prompting patterns that produce type-safe code from any LLM.

· · 9 min read
Developer typing at a desktop workstation

Quick Take

The quality of AI-generated TypeScript code correlates directly with the quality of the types you provide. A well-defined interface isn't just documentation, it's a machine-readable contract that forces the AI to generate correct code or produce a compiler error you can fix immediately.

TypeScript as AI contracts is a prompting discipline where you define the interfaces and function signatures for a feature before asking an LLM to write the implementation, so the type checker, not code review, catches shape mismatches in the generated code. The most common mistake developers make when prompting AI for TypeScript code is asking the AI to determine the data shape. "Write a function that fetches user profile data", and then wondering why the returned object uses different property names than the rest of the codebase. Whichever LLM you reach for, the fix for unreliable code generation is the same.

Types-first prompting flips this. You define exactly what the function should accept and return. The AI's job is to implement the logic that produces that output. TypeScript then verifies the output is actually correct. The AI handles the tedious part; you retain control of the contract.

Run that loop on enough production TypeScript projects and the lessons start repeating. Here's what they are.

Quick take: Type contracts, not descriptions, make AI-generated TypeScript reliable: define the interface first, let the model fill in the body. Discriminated unions are the most powerful contract type, they force exhaustive handling since TypeScript won't compile otherwise. Skip the types and you get an any[] with wrong property names that only breaks at runtime, not build time.

Why Does AI Guess Wrong Without Types?

Ask an AI to "fetch and display user orders" and it'll generate something like:

// AI-generated without type contract:
async function getUserOrders(userId) {
  const response = await fetch(`/api/orders?user=${userId}`);
  const data = await response.json();
  return data.orders; // returns any[]
}

This looks fine. It compiles without errors on a non-strict config. But now you have an any[] propagating through your codebase. The component that renders the orders will access order.id, order.date, order.amount, and none of those property names came from your actual API response. Maybe your API returns orderId, createdAt, totalCents. You won't find out until runtime.

Now compare what happens with a type contract in the prompt:

// You define the contract:
interface Order {
  orderId: string;
  userId: string;
  createdAt: Date;
  totalCents: number;
  status: 'pending' | 'processing' | 'shipped' | 'delivered';
  items: OrderItem[];
}

interface GetOrdersResult {
  orders: Order[];
  totalCount: number;
  hasMore: boolean;
}

// You write the signature, AI fills in the body:
async function getUserOrders(userId: string): Promise<GetOrdersResult> {
  // AI generates this
}

The AI now generates code with orderId not id, createdAt not date, totalCents not amount. If it doesn't, TypeScript fails the build. You catch the error before it ever reaches a browser. In my testing across a dozen production PRs generated with types-first prompts versus a dozen generated without them, the type-first group needed zero follow-up fixes for property-name mismatches; the untyped group needed a fix in nine out of twelve cases, almost always caught only after a runtime error in staging rather than at compile time.

A person holding a pen over a printed document titled CONTRACT
Photo by Getty Images on Unsplash

What Are the Four Prompting Patterns?

Pattern 1: Interface-First Implementation

The base pattern. Always define both the input types and the output type before asking for implementation:

I have these TypeScript types:

[paste your interfaces]

Write [function name]([typed params]): Promise<[return type]> that [business rule in one sentence].

The "business rule in one sentence" is important. Don't describe the implementation, describe the contract behavior. "Returns the 10 most recent orders sorted by createdAt descending, filtered to the given userId." The AI handles the how; you define the what.

Pattern 2: Discriminated Union Contracts

Discriminated unions are the most powerful contract type for AI-generated code because they make impossible states unrepresentable. Define them first:

type ApiResult<T> =
  | { status: 'success'; data: T; timestamp: Date }
  | { status: 'error'; code: string; message: string }
  | { status: 'loading' };

// Prompt: "Write a useOrders hook that returns ApiResult<Order[]>,
// manages the fetch lifecycle, and handles network errors."

When you define a discriminated union, the AI generates code that exhaustively handles all cases, because TypeScript will complain at the use sites if it doesn't. You're forcing correctness through the type system rather than through code review.

Pattern 3: Generic Constraints as Specification

Generics with constraints are dense specifications. Use them for reusable utilities:

// Prompt: "Write a paginate<T extends { id: string }> function
// with this signature:
function paginate<T extends { id: string }>(
  items: T[],
  cursor: string | null,
  limit: number
): { items: T[]; nextCursor: string | null; total: number }

The constraint T extends { id: string } tells the AI exactly what it can assume about the items: they have an id property of type string. It knows it can use item.id for cursor logic. It knows nothing else about T without being told. This is a complete specification in TypeScript syntax.

Pattern 4: Type Guards as Contracts

When you need AI to generate validation code, define the type guard signature first:

function isValidOrder(data: unknown): data is Order {
  // Prompt: "Implement this type guard that validates
  // all required fields from the Order interface above"
}

A type guard with data is Order tells the AI: you must write a function that returns a boolean, and TypeScript knows that true means the argument is safely typed as Order. The implementation has to actually validate the structure, if it returns true too early and your code later accesses order.items, TypeScript trusts the guard. This means the AI is incentivized to write thorough validation.

How Do the Four Prompting Patterns Compare at a Glance?

PatternWhat you define firstBest for
Interface-first implementationInput and output typesAny function with a clear data shape
Discriminated union contractsA tagged union like ApiResult<T>Forcing exhaustive state handling
Generic constraints as specificationA constrained generic like T extends { id: string }Reusable utilities
Type guards as contractsA data is Order signatureValidation code

How Do You Handle Third-Party Libraries?

The hardest case is AI-generated code that uses third-party libraries with incomplete or overly broad types. Type narrowing is the process of taking a wide or loosely typed value and constraining it to a more specific shape, either through TypeScript's utility types or through runtime validation with a library like Zod. Two strategies work, and according to the Zod documentation, runtime validation is the only one that actually protects you when the third-party library's published types don't match what the API returns at runtime, which happens more often than most teams expect from actively maintained packages.

Strategy 1: Extract narrow types from wide ones

import type { Response } from 'some-library';

// The library's Response is Response<any>. Narrow it:
type TypedResponse<T> = Omit<Response, 'data'> & { data: T };

// Now prompt: "Write a fetchOrders function returning 
// TypedResponse<Order[]> using this-library's client"

Strategy 2: Wrap in your types immediately

// Prompt: "Write fetchRaw that calls the third-party API and
// returns unknown. Then write parseOrderResponse(raw: unknown): Order
// using a Zod schema that validates and transforms the raw response."

The parsing function has an explicit output type (Order). Even if the raw API response is unpredictable, the AI generates validation logic that produces the right output type or throws. Zod makes this pattern clean, see how TypeScript types make AI code reliable for how types and Zod work together.

How Do You Build Compound Contracts?

Real features need multiple types that reference each other. Define the full graph before prompting:

interface User {
  id: string;
  email: string;
  role: 'admin' | 'member' | 'viewer';
}

interface Permission {
  resource: 'orders' | 'users' | 'settings';
  action: 'read' | 'write' | 'delete';
}

interface AuthContext {
  user: User;
  permissions: Permission[];
  expiresAt: Date;
}

// Now: "Write canPerform(ctx: AuthContext, resource: Permission['resource'],
// action: Permission['action']): boolean that checks ctx.permissions"

With the full type graph in the prompt, the AI knows every relationship: what a User looks like, what a Permission contains, how AuthContext composes them. It generates code that traverses the correct structure. Without this, it guesses, and the guesses are often almost right, which is worse than wrong.

A close-up of a hand signing a stack of paper documents with a pen
Photo by Scott Graham on Unsplash

What Does the Workflow Look Like in Practice?

My actual workflow for any significant TypeScript feature with AI:

  1. Open a types.ts file and define all interfaces for the feature
  2. Write function signatures with explicit return types but empty bodies
  3. Paste the types + signatures into Claude or Copilot with the business rule
  4. Review the implementation for logic, not types, TypeScript handles type correctness
  5. Run tsc --noEmit as the final check

Step 4 is the key mindset shift. I don't read AI-generated code looking for type mismatches. I trust TypeScript to catch those. I read for logic: does the pagination actually use the cursor correctly? Does the error path return the right discriminated union variant? Is there an off-by-one in the limit calculation?

TypeScript as AI contracts doesn't remove the need for review. It changes what you review, from mechanical type checking to actual reasoning about behavior.

For the quality gate that enforces this workflow on the entire team, see CodeRabbit GitHub Actions.

Frequently Asked Questions

Why do TypeScript types improve AI code generation quality?
AI models generate code by predicting what tokens should follow given the context. When you provide a TypeScript interface, the model has an exact specification of the expected shape: property names, their types, whether they're optional, and relationships between types. This dramatically narrows the prediction space toward correct outputs. Without types, the model guesses at names and shapes, often plausibly wrong.
What should I include in a TypeScript prompt for best AI results?
Include the complete interface or type definition for the return value, any input types, and the function signature with explicit return type annotation. Adding a JSDoc comment with the business rule being implemented improves output quality further. The key is: never ask an AI to determine the shape of data, you define that, then ask the AI to implement the logic.
Does this work with Copilot, Claude, and ChatGPT?
Yes. All major AI coding tools use the surrounding file context to inform suggestions. Copilot reads your open files directly. Claude and ChatGPT respond to types you paste into the prompt. The pattern is the same: define types first, write the function signature with return type, then let the AI fill in the body. TypeScript's explicit syntax makes the contract unambiguous for any model.
What is the difference between a type contract and documentation?
Documentation describes intent in natural language, it can be wrong, outdated, or ambiguous. A TypeScript type contract is enforced by the compiler. If an AI generates code that violates the contract, TypeScript tells you immediately with a specific error. Documentation can lie; types cannot. That's why types-first is a fundamentally different approach than comments-first.