Type-Driven AI: Getting Better Code by Sharing TypeScript Types First
How sharing your TypeScript types before asking for implementations dramatically improves AI-generated code — and how to use the compiler as a fast verification loop.
When you ask an AI to write a function that processes a user, it guesses. When you paste in your User type first, it doesn’t have to.
TypeScript types are precise machine-readable specifications — exactly the kind of context that makes AI-generated code fit your codebase instead of just looking plausible. The habit of sharing types before asking for implementations is one of the cheapest improvements you can make to how you work with AI coding tools.
Why AI-generated code breaks on types
The model knows TypeScript. It doesn’t know your TypeScript — your naming conventions, your optional fields, your discriminated union shapes. When you ask for a function without sharing these, it makes reasonable-looking assumptions:
- Assumes
user.namewhen you haveuser.displayName - Returns
{ success: boolean }when you expect{ ok: boolean; message: string } - Handles null with
?? undefinedwhen your codebase uses strict null types
The output type-checks in isolation but breaks when you integrate it. The fix is simple: give the model the types first.
Paste types before the request
Here’s the difference in practice. A request without types:
Write a function that creates a new post for a user and returns it.
The model will guess what Post and User look like. Now the same request with types:
Here are the relevant types:
interface User {
id: string;
displayName: string;
role: "admin" | "member";
}
interface Post {
id: string;
authorId: string;
title: string;
body: string;
publishedAt: Date | null;
draft: boolean;
}
type CreatePostInput = Pick<Post, "title" | "body"> & { draft?: boolean };
Write a function that takes a User and CreatePostInput and returns a Post.
The authorId should come from the user. Unpublished posts have publishedAt as null.
The model now has the exact shape of every value involved. Any field name, return type, or optionality that deviates from what you specified will surface immediately as a compiler error — before you’ve read a single line of the generated implementation.
Generate types first, then implementations
Sometimes you need to generate both. The most reliable pattern is to ask for types in a first pass, review them, then ask for implementations in a second pass.
Pass 1 — types only:
I'm building a webhook handler for Stripe payment events. I need to handle
these three: payment_intent.succeeded, payment_intent.payment_failed, and
customer.subscription.deleted.
Generate TypeScript types for the incoming webhook payloads and a handler
function signature. No implementation yet — types and signatures only.
The model returns something like:
type PaymentSucceededPayload = {
type: "payment_intent.succeeded";
data: {
object: { id: string; amount: number; currency: string; metadata: Record<string, string> };
};
};
type PaymentFailedPayload = {
type: "payment_intent.payment_failed";
data: {
object: { id: string; failure_message: string | null };
};
};
type SubscriptionDeletedPayload = {
type: "customer.subscription.deleted";
data: {
object: { id: string; customer: string };
};
};
type StripeEvent =
| PaymentSucceededPayload
| PaymentFailedPayload
| SubscriptionDeletedPayload;
async function handleStripeWebhook(event: StripeEvent): Promise<{ received: boolean }>;
Read the types. Add fields you know are missing — Stripe sends more than the model guessed. Narrow the union if needed. Once they’re right, hand them back.
Pass 2 — implementation:
Here are the finalized types:
[paste your corrected types]
Now implement handleStripeWebhook. For payment_intent.succeeded, call
processPayment(). For failures, call markPaymentFailed() and notify the user.
For subscription.deleted, call cancelSubscription(). Assume all three
functions exist and are already imported.
The implementation comes back fully typed. Because the model has your finalized shapes, any mismatch with your actual processPayment() signature will be a compiler error, not a runtime surprise.
Use the compiler as a fast feedback loop
Once you have AI-generated code, tsc --noEmit is your fastest verification tool. It takes a few seconds and catches shape mismatches, missing fields, and wrong return types before you’ve read the implementation carefully.
Run it immediately after pasting in AI output:
npx tsc --noEmit --strict
When it flags something, feed the error back verbatim:
The compiler flagged this error on line 14:
Type 'string | null' is not assignable to type 'string'.
Type 'null' is not assignable to type 'string'.
The customer field can be null when the customer record was deleted before
the webhook fired. Fix the type and add a null guard before using it.
This loop — generate, compile, feed errors back — is much faster than manual debugging. The error is precise, the model can fix it without additional context, and you can iterate in under a minute per round.
The pass-two habit — Josh: I started splitting type generation from implementation after a session where I asked for everything at once. The model invented a
Post.authorfield that didn’t exist in our schema, and I spent fifteen minutes tracing an integration error back to a name mismatch. The two-pass approach costs ten seconds. It saves fifteen minutes, reliably. I paste the types in, read them, correct what’s wrong, then ask for the implementation. The output compiles on the first try almost every time.
Typed schemas are better than inferred types
If you’re using Zod, Drizzle, or Prisma, paste the schema in — not just the inferred type. The schema carries constraints the type doesn’t express:
// Share this:
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
displayName: z.string().min(2).max(50),
role: z.enum(["admin", "member"]),
createdAt: z.coerce.date(),
});
// Not just this:
type User = z.infer<typeof UserSchema>;
When the model sees displayName: z.string().min(2).max(50), it knows to add a runtime length check in any code that accepts user input — not just use the type statically. The same applies to Drizzle table definitions and Prisma schemas, which carry unique indexes, foreign keys, and default values that influence what correct code looks like.
The implementation the model generates from a Zod schema will tend to include validation that matches what the schema already enforces. The implementation generated from the inferred type User won’t — it’s just field names and types.
Discriminated unions deserve their own request
When you have a discriminated union — route handlers, event payloads, state machines — paste the full union and ask the model to handle each variant explicitly:
type AuthEvent =
| { type: "login"; userId: string; device: string }
| { type: "logout"; userId: string }
| { type: "password_reset"; email: string; tokenExpiry: Date };
Write a function handleAuthEvent(event: AuthEvent) that logs each event type
differently. Handle each variant of the union explicitly — no default or
catch-all branch.
The “no catch-all” constraint matters. It forces the model to produce code where TypeScript’s exhaustiveness checking will catch a new variant added later. Without it, the model tends to write a default branch that silently swallows any union member you add in the future.
What types can’t tell the model
Types specify shape. They don’t specify behavior. A model given a perfect set of types can still implement the wrong logic — a function that correctly returns a Post but assembles the body wrong, or handles a role check backward.
Type-driven prompting reduces shape errors. It doesn’t replace behavioral review. The post on Evaluating AI-Generated Code Before It Ships covers what to look for in the implementation itself. These two practices stack well: types catch structural problems fast; behavioral review catches what types can’t see.
For multi-step features where AI generates multiple layers — schema, service functions, route handlers, tests — types shared between sessions act as the interface contract that keeps layers coherent. This pairs naturally with the segmented-session approach in Managing Context in Long AI Coding Sessions: one session per layer, types as the handoff artifact between them.
The practical habit
Two things to keep consistent:
Paste types at the top of every implementation request. Even when the model could probably infer the shapes. The cost is a few lines of context; the payoff is code that integrates without fixup.
Run tsc --noEmit before reviewing the logic. Structural errors are fast to find this way and slow to find by reading. Clear the type errors first, then evaluate the behavior.
TypeScript is designed to make invalid states unrepresentable. When you share that contract with an AI coding assistant, you’re giving it the same guide the compiler uses — and then you get to use the compiler to verify what came back.