One Convex Schema to Rule Them All (And Why It Might Break TypeScript)
The Siren Song of DRY
Look, I need to confess something: I fell for it. Hard.
The promise was intoxicating: One schema to rule them all. A single source of truth. Convex database schemas that automatically become API types that automatically become UI types. Change it once, update everywhere. The DRY principle taken to its logical, beautiful conclusion.
I mean, who doesn't want that? It's the architectural equivalent to comfort food, or Captain America. Consistent types makes this dev feel nice and safe, and when I booted up my latest project and made a central "models" folder from which I'd drive my schema in Convex I made the same face Deadpool did when "not his favorite Chris" showed up:

But much like Deadpool's hopes were dashed when Chris was Johnny and not Steve (IYKYK) so my central type feavor dream was based to bits when the TS runtime started throwing TS2589 errors at me (flame on indeed).
But lets be honest, I deserved it. I had built something that looked elegant from the outside but was actually a tightly-coupled monstrosity held together with type inference and hubris.
This is the story of how I learned that sometimes—sometimes—duplication is cheaper than abstraction.
The Fellowship of Types: Building My Monolith
Here's how it started. Like all good disasters, it began with the best of intentions.
I created a models/ folder. This would be my cathedral of types—a beautiful, centralized place where all schema definitions would live in harmony. Inside: forms.ts, fileAssets.ts, formInstances.ts, and a dozen other files, each containing Zod validators that described the shape of my data.
Then came convex/schema.ts, which imported these validators to define my Convex database schema. Convex would generate all those lovely typed helpers—Doc<"forms">, Id<"fileAssets">, the works.
And finally, my React components would import types directly from models/, inferring everything they needed from the same validators that defined the database.
Chef's kiss. One schema, three layers. Change a field in models/forms.ts and boom—database, API, and UI all update together. No manual syncing. No stale types lurking in forgotten corners of the codebase.
For a while, this was glorious. Development velocity was incredible. I could add a new field to a form schema, and within seconds, TypeScript was yelling at me about every place I needed to handle that field. It was like having a very pedantic but helpful friend who follows you around pointing out things you forgot.
The problem? That friend was about to have a nervous breakdown.
When TypeScript Starts Screaming
The first time I saw it, I thought I'd made a typo.
TS2589: Type instantiation is excessively deep and possibly infinite
I stared at this error in my use-forms.ts hook, wondering what cosmic rule I'd violated.
Okay, hold on. I just threw out "TS2589" like you're supposed to know what that means. Let me back up for those of you who haven't spent unhealthy amounts of time spelunking through TypeScript compiler errors (lucky you). TS2589 is just the error code—TypeScript's way of saying "I give up". When the compiler tries to recursively expand a type and the nesting gets too deep—or when it detects what might be infinite recursion—it just... stops. Throws its hands in the air. Goes on strike.
The code looked fine. I was just using some Convex-generated types with some generics. Standard stuff.
I did what any reasonable developer does: I Googled it.
And here's the thing: you don't need explicitly recursive types to trigger this. Even "correct" types can hit this limit when you combine generics, mapped types, conditional types, and deep composition in just the wrong way.
Think of it like this: TypeScript's type system is trying to play chess, but you've handed it a game state that requires calculating 47 moves ahead. Eventually, it's going to tap out.
In my case, the problem was a perfect storm:
- Convex generates complex types from your schema—
Doc<>types,Id<>types, query return types, all deeply nested. - My shared validators were importing each other (
forms.tsimportsfileAssets.tsimportsformInstances.tsimports...). - React hooks were using generic patterns with these already-complex types.
- Everything was interconnected through that single
models/folder.
The type dependency tree looked less like a tree and more like a Gordian knot.
As of today (Dec 1st 2025), there's even an open GitHub issue on Convex's repo about TS2589 appearing in monorepo setups. I wasn't alone in my suffering. Solidarity through pain.
The Coupling Problem (Or: How I Built a House of Cards)
But the TS2589 errors were just a symptom. The real disease was tight coupling.
See, when your UI components import types from models/—types that were originally designed to describe database schemas—you've created a dependency that flows the wrong direction. The UI is now "enslaved" to backend design decisions.
Here's a concrete example from my app: My form viewer component needed to display form data. But the shape it needed was different from the database schema. I wanted to flatten some nested objects, rename fields for clarity, and hide internal metadata that users shouldn't see.
But I couldn't. Well, I could, but it meant touching the central schema file. Which meant potentially breaking the database layer. And the API layer. And every other UI component that imported that type.
This is what software architects call "change amplification"—when a single logical change requires modifications across multiple layers. It's like trying to rearrange furniture in your living room but discovering that your couch is load-bearing.
The build-time dependencies got ugly too. My frontend build depended on backend types existing and compiling correctly. Backend type changes forced frontend rebuilds. The modularity I thought I was achieving? Illusion. I had created a distributed monolith wearing a microservices costume.
The Hidden Costs: What I Didn't See Coming
Complexity Explosion
Every time TypeScript encountered one of my types, it had to fully "instantiate" the entire dependency tree. And because everything imported everything else, that tree was... let's call it "ambitious."
My forms.ts imported validators from fileAssets.ts. Which imported from formInstances.ts. Which imported from userProfiles.ts. Which probably imported from forms.ts somewhere down the line because circular dependencies are the cockroaches of software—they find a way.
Heavily composed types—the kind that database query builders and ORMs love—are especially prone to triggering TS2589. Each layer of composition adds exponential complexity to the type system's resolution algorithm.
My editor started lagging. TypeScript's language server would occasionally just... give up and restart. I'd make a change and wait 15 seconds for the type-checking to complete. That's when you know you've made a mistake.
Loss of Flexibility
The "one size fits all" approach meant I couldn't optimize types for different layers.
The database needed strict types with internal metadata, timestamps, relational IDs, and invariants about data consistency. The API needed types that exposed only public fields and perhaps formatted data for network transfer. The UI needed lightweight, consumer-friendly types optimized for rendering and form handling.
Instead, I had one type trying to be all three. It was like forcing someone to wear their pajamas to a business meeting because "hey, they're both outfits."
Migrations became all-or-nothing affairs. Want to add a field? Great—now you need to handle it in every layer simultaneously. No gradual rollouts. No feature flags. Just rip off the band-aid and hope you didn't miss anything.
Development Velocity Crater
Because I try to engineer proactively I could see the writing on the wall. Type errors in the backend would break unrelated frontend code. Two developers working on different features would collide on the same schema file. Pull requests became archeological expeditions through merge conflicts.
The more people on the team, the worse it would get. It would be like everyone was trying to edit the same Google Doc simultaneously, but instead of text, we were editing the fundamental structure of reality.
The @ts-expect-error Smell
You know you're in trouble when you start seeing this pattern:
// @ts-expect-error TS2589 - TODO: fix this later
const formData = useQuery(...);
That's the smell of technical debt accumulating. Each @ts-expect-error is a little lie you're telling yourself: "This is fine. The types are probably right. We'll come back and fix it."
You won't. Nobody does. These comments are tombstones marking the graves of good intentions.
And here's the kicker: by suppressing these errors, you're undermining the entire reason you wanted "one schema" in the first place—end-to-end type safety. Congratulations, you've built an elaborate system that doesn't actually guarantee type safety. It's security theater, but for your type system.
What Gandalf Would Do: The Path of Decoupled Types
So what's the alternative? After digging myself out of this hole, here's what I learned:
Layer-Specific Types
Keep your database schema in the database layer. For Convex, that means convex/schema.ts stays in the convex/ folder. The generated types live there too.
Define API types at the API boundary—the shape of data going over the wire, the structure of your RPC payloads.
Define UI types in the UI layer—optimized for components, forms, and rendering. These should be simple, flat, and ignorant of database internals.
Yes, this means duplication. Yes, that feels wrong if you've been indoctrinated by the Church of DRY. But duplication is far cheaper than coupling.
Bounded Contexts
Each module or feature should define types that are just complex enough for its needs. No more, no less.
Your user profile display component doesn't need to know about database indexes or foreign key relationships. Give it a simple interface:
interface UserProfileDisplay {
name: string;
avatarUrl: string;
joinedDate: string;
}
That's it. The component doesn't care how you store this in the database. It doesn't care about your Zod validators or your Convex schema. It just wants those three fields.
Type Adapters at Boundaries
Use small, explicit conversion functions between layers:
function toUIForm(dbForm: Doc<"forms">): UIForm {
return {
id: dbForm._id,
name: dbForm.name,
createdAt: new Date(dbForm._creationTime).toLocaleDateString(),
// Only the fields the UI actually needs
};
}
These adapters are your controlled boundaries. They're the checkpoints where you decide what crosses from one layer to another. They make coupling explicit and manageable.
Practical Patterns
Instead of importing one monolithic type everywhere:
// DON'T DO THIS
import { Form } from '@/models/forms'; // Used everywhere
Create view-specific types:
// DO THIS
type FormListItem = Pick<Form, 'id' | 'name' | 'createdAt'>;
type FormDetail = Omit<Form, 'internalMetadata'>;
Or better yet, define UI types independently:
interface FormListItem {
id: string;
name: string;
createdAt: string;
itemCount: number;
}
Now the UI type isn't coupled to the database schema at all. You're free to change either without affecting the other.
When One Schema Actually Makes Sense
I'm not saying the "one schema" approach is always wrong. Like most things in software, it depends.
Small apps: If you have fewer than five tables, simple relationships, and you're working solo or with a tiny team on a short-lived project? Go for it. The coupling won't hurt you at that scale.
Truly shared types: Enums, constants, configuration objects—things that really do have identical meaning across all layers. Yes, share those.
Simple CRUD APIs: If your API is literally just exposing database operations with no transformation, one schema might work. Though even then, I'd argue for API-specific types to maintain the boundary.
The key word is simple. If your app is simple and will stay simple, optimize for speed. But if you're building something that might grow, might get complex, might have multiple developers? Build in the boundaries from the start.
The Refactoring Journey: Breaking Free
Immediate Fixes
If you're currently drowning in TS2589 errors, here's your emergency flotation device:
Add skipLibCheck: true to your tsconfig.json. This tells TypeScript to skip type-checking in .d.ts files, including Convex's generated types. Many people do this when dependencies become too heavy for the compiler.
Flatten complex types at usage boundaries. Instead of:
const data = useQuery(api.forms.get, { id }); // Returns mega-complex inferred type
Do this:
const data = useQuery(api.forms.get, { id });
const formData: SimpleFormData = data; // Explicit, simpler type
Split mega-unions and intersections into smaller, focused types.
Long-Term Strategy
- Audit your types: Which ones actually need to be shared? Probably fewer than you think.
- Build layer-specific types: Database types in
convex/, API types inapi/types/, UI types incomponents/types/. - Keep database schemas private: Frontend code should rarely import directly from
convex/schema.ts. - Build an adapter layer: DTOs, serializers, mappers—whatever you want to call them, create explicit translation functions between layers.
Migration Path
You don't need to refactor everything at once. (Please don't. Learn from my mistakes.)
- Identify the types causing TS2589—usually the largest, most deeply nested schemas.
- Create UI-specific versions of those types.
- Refactor UI code to use the new types.
- Gradually decouple other types, feature by feature.
- Optionally, set up linting rules to enforce boundaries (e.g., "UI code cannot import from
convex/").
Lessons from Mount Doom
Here's what I learned from this adventure:
DRY is not always your friend. Sometimes duplication is cheaper than coupling. Sometimes the cost of maintaining a "single source of truth" exceeds the cost of manually keeping two things in sync.
TypeScript has limits. The compiler has real performance constraints. Complex types have cognitive costs. Your IDE slowing to a crawl is not a minor inconvenience—it's a productivity killer.
Frontend ≠ Backend. They have different concerns, different constraints, different needs. Treating them as the same thing forces compromises that hurt both.
Boundaries matter. Clear separation with explicit contracts often yields more maintainable code than clever abstraction. Boring is good. Boring is understandable. Boring scales.
Start simple, stay simple. Don't over-architect early. Add abstraction when you need it, not because it seems elegant. Your job is to solve problems, not to build architectural monuments to your own cleverness.
The Trade-off Matrix
| Approach | Pros | Cons |
|---|---|---|
| One Schema | Fast start, single truth, consistent types | Complexity explosion, tight coupling, fragility |
| Decoupled Types | Flexibility, separation of concerns, maintainable | More upfront work, type duplication across layers |
Choose Your Own Adventure
There's no silver bullet here. The right architecture depends on your context—team size, app complexity, growth trajectory, how much you enjoy debugging TS2589 errors at 2 AM.
For my app, decoupling is clearly the path forward. I'm gradually breaking apart the monolithic schema, building adapter layers, and accepting that a little duplication is the price of flexibility.
The real lesson? Architecture is about managing complexity over time. Sometimes the "right" pattern becomes the wrong one as you scale. The trick is recognizing when you've crossed that threshold and being willing to refactor.
I'd love to hear your war stories. What's your "One Schema" horror story? Have you successfully maintained a unified schema across layers, or did you also end up rage-refactoring at midnight? Drop a comment or find me on LinkedIn—I'm always up for trading battle scars.