⚡ Promptolis Original · Coding & Development
🏷️ TypeScript Type System Architect
Designs your TypeScript type strategy: where strict types pay off, where they cost more than they save, the 8 advanced patterns that solve real problems, and the 5 anti-patterns that produce 'unreadable types' code review nightmares.
Why this is epic
TypeScript can be a productivity multiplier OR a productivity tax — the difference is type strategy. This Original picks where strict types pay off (boundaries, public APIs, business invariants) and where 'just any' is acceptable.
Outputs the 8 advanced patterns worth knowing (discriminated unions, branded types, conditional types, mapped types, template literals, zod-derived types, function overloads, type-level state machines) with real examples and when to use each.
Calls out the 5 anti-patterns: type assertions everywhere, deeply nested generics that nobody understands, type gymnastics that take an afternoon to write, types that don't reflect runtime behavior, ignoring tsconfig strict flags.
Calibrated to 2026 reality: the trend toward inferred types (don't over-annotate), zod/valibot for runtime + compile-time, the rise of `satisfies` operator, the death of namespace + module declarations in favor of ESM.
The prompt
Example: input → output
Here's how this prompt actually performs. Real input below, real output from Claude Opus 4.
<codebase>Next.js 14 + tRPC + Postgres + Drizzle ORM. ~80K LOC. Mono-repo. B2B SaaS at $1M ARR.</codebase> <current-tsconfig>strict: true. noImplicitAny: true. strictNullChecks: true. noUncheckedIndexedAccess: false (this bites us often). exactOptionalPropertyTypes: false.</current-tsconfig> <biggest-pain>(1) Our tRPC types are hard to read — deeply nested `inferProcedureOutput` chains. Engineers avoid changing them. (2) We have ~200 places where someone wrote `as any` to bypass type errors. (3) We've had 3 production bugs in 6 months where API response shape changed but TS didn't catch it (because we use as-casts). (4) Junior engineers find the codebase intimidating because types are dense.</biggest-pain> <api-surface>tRPC procedures (~80 of them). Internal helpers + utilities. No public NPM library.</api-surface> <runtime-validation>zod for tRPC input validation. NOT used for output (we trust DB types from Drizzle).</runtime-validation> <team>4 backend engineers + 1 frontend. Mid-level. None are TypeScript experts; they use what's already there.</team> <specific-problems>(1) `Awaited<ReturnType<typeof someTrpcProc>>` patterns are everywhere and confusing. (2) We have a UserSettings type that's grown to 80 fields; some optional, some required — hard to reason about which combinations are valid. (3) We use `as any` in tests because mocking types is too hard.</specific-problems>
Type System Strategy: Next.js + tRPC + Drizzle SaaS
Diagnosis
Three concrete issues:
1. Type complexity at API boundary. `Awaited<ReturnType<typeof someTrpcProc>>` is the 'tRPC tax.' Fix: explicit type aliases at boundaries.
2. `as any` everywhere. Indicates types are fighting devs, OR runtime data doesn't match types. Either way: eliminate.
3. No runtime validation on outputs. When DB schema drifts from app expectations, types lie. Add zod validation at outputs (or at least at critical boundaries).
Highest-leverage fixes:
1. Add `noUncheckedIndexedAccess: true` — catches one major class of bugs at near-zero cost.
2. Eliminate `as any` via codemod or audit. Each one is a bug surface.
3. Build named type aliases for tRPC outputs — replaces deeply-nested inferences with readable names.
4. Add zod schemas for critical API outputs — runtime + compile-time guarantee.
Estimated outcome: type-related production bugs drop ~70%. Junior engineers more comfortable. Senior engineers do less type-firefighting.
Strictness Strategy
Strict everywhere by default. Loosen only with explicit comments.
/src/api/ — STRICT (public boundary, customer-facing)
/src/lib/business/ — STRICT (business logic, invariants matter)
/src/lib/external/ — STRICT (external API integrations)
/src/lib/utils/ — STRICT (pure utilities, easy to type well)
/src/db/ — STRICT (DB types from Drizzle, drift = bugs)
/src/components/ — STRICT but pragmatic (allow `as` for DOM types)
/tests/ — STRICT (tests should reflect production types)
/scripts/ — LOOSE OK (one-time scripts, not production)
For any place needing exception: explicit comment block:
// TypeScript-suppressed: <reason>
// TODO: <ticket> — fix by <date>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const foo: any = ...;
If comment block doesn't appear, lint rejects.
tsconfig Recommendations
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"alwaysStrict": true,
// Add these:
"noUncheckedIndexedAccess": true, // arr[i] returns T | undefined. Catches index-out-of-bounds bugs.
"exactOptionalPropertyTypes": true, // Distinguishes { x?: T } from { x: T | undefined }. Tighter.
"noImplicitOverride": true, // Class methods need explicit override.
"noUnusedLocals": true, // Lint catches unused vars.
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
// Module resolution
"moduleResolution": "bundler", // 2026 standard for Next.js
"module": "esnext",
"target": "ES2022",
// Quality of life
"skipLibCheck": true, // Don't typecheck node_modules
"forceConsistentCasingInFileNames": true,
"isolatedModules": true // Each file compiles independently
}
}
noUncheckedIndexedAccess will surface ~20-50 bugs in your codebase initially. Fix them. Each is a real potential runtime crash.
The 8 Patterns Worth Using
1. Discriminated unions
For any 'one of N states' modeling:
// Instead of:
type Result<T> = {
status: 'success' | 'error';
data?: T;
error?: string;
};
// Use:
type Result<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: string };
// Now TypeScript narrows correctly:
if (result.status === 'success') {
console.log(result.data); // T, not T | undefined
}
When to use: API responses, state machines, error handling.
2. Branded types
For domain primitives that should be type-distinct:
// Brand technique
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
// Constructor function
function toUserId(s: string): UserId {
return s as UserId; // controlled cast
}
// Now this is a type error:
function findOrder(id: OrderId) { /* ... */ }
function handleUser(userId: UserId) {
findOrder(userId); // ERROR: UserId not assignable to OrderId
}
When to use: IDs, money amounts (don't mix dollars + cents), validated strings (Email, URL).
3. zod-derived types
Single source of truth — schema validates runtime, type derives compile-time:
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.date(),
});
type User = z.infer<typeof UserSchema>;
// type User = { id: string; email: string; role: 'admin' | 'user' | 'guest'; createdAt: Date }
// At runtime boundary:
const user = UserSchema.parse(rawData); // validates + types
When to use: API inputs/outputs, config files, external data, anywhere you need runtime guarantees that match types.
4. Mapped types for transformation
type Partial<T> = { [K in keyof T]?: T[K] }; // built-in
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
// Specific patterns:
type WithoutSensitive<T> = Omit<T, 'password' | 'token' | 'secret'>;
type DatabaseRecord<T> = T & { id: string; createdAt: Date; updatedAt: Date };
type Nullable<T> = { [K in keyof T]: T[K] | null };
When to use: deriving types from existing types. Avoid for unique one-off shapes (just write the type).
5. Template literal types
For strings with structure:
type Route = `/users/${string}` | `/orders/${string}`;
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = `${HttpMethod} ${Route}`;
// type Endpoint = 'GET /users/...' | 'POST /users/...' | 'GET /orders/...' | ...
// Practical:
type EventName = `user.${string}` | `order.${string}` | `payment.${string}`;
When to use: route patterns, event naming conventions, structured strings.
6. The `satisfies` operator
// Without satisfies — type widens:
const config = {
apiUrl: 'https://api.example.com',
timeoutMs: 5000,
};
// config.timeoutMs is `number`, not `5000`
// With satisfies — preserves narrow type:
const config = {
apiUrl: 'https://api.example.com',
timeoutMs: 5000,
} satisfies { apiUrl: string; timeoutMs: number };
// config.timeoutMs is `5000` (literal type)
// + validates shape matches the constraint
// Replaces 90% of `as` usages
When to use: object literals where you want validation but not type-widening.
7. Function overloads (rarely)
function process(input: string): string;
function process(input: number): number;
function process(input: string | number): string | number {
// implementation
}
When to use: rarely. Most cases solved by generics. Use when caller's type informs return type AND generics get unwieldy.
8. Type-level state machines (advanced)
type Order =
| { state: 'pending'; items: Item[] }
| { state: 'paid'; items: Item[]; paidAt: Date }
| { state: 'shipped'; items: Item[]; paidAt: Date; shippedAt: Date; trackingNumber: string }
| { state: 'delivered'; items: Item[]; paidAt: Date; shippedAt: Date; deliveredAt: Date };
// Functions enforce state transitions:
function ship(order: Extract<Order, { state: 'paid' }>): Extract<Order, { state: 'shipped' }> {
return { ...order, state: 'shipped', shippedAt: new Date(), trackingNumber: '...' };
}
When to use: domain models with strict state transitions where wrong transitions are bugs.
Anti-Patterns to Eliminate
1. `as any` (your top issue)
200 instances in your code = 200 places types are bypassed. Migration plan:
# Find all
grep -rn 'as any' src/
# Categorize:
# - Test mocks → use jest.fn() with proper types or vi.MockedFunction
# - API response casts → use zod.parse() instead
# - Library workarounds → upgrade library or use module declarations
# - 'I just want this to work' → audit individually
Replace with:
as SomeType(acceptable — narrowed cast you've thought about)satisfies SomeType(preferred — validates without widening)zodSchema.parse(value)(best — runtime + type)- Refactor types so cast isn't needed
Add ESLint rule: @typescript-eslint/no-explicit-any to prevent regressions.
2. Deeply nested generics
// BAD
type Foo<T> = T extends Array<infer U> ? U extends object ? Partial<U> : never : never;
// BETTER
type Item<T> = T extends Array<infer U> ? U : never;
type PartialIfObject<T> = T extends object ? Partial<T> : never;
type Foo<T> = PartialIfObject<Item<T>>;
If a type takes 3+ chained extends infer, break it into named pieces.
3. tRPC type churn
Your Awaited<ReturnType<typeof someTrpcProc>> pain. Solution: explicit type aliases.
// Define at the API layer:
export type GetUserOutput = inferProcedureOutput<typeof appRouter.user.get>;
export type CreateUserInput = inferProcedureInput<typeof appRouter.user.create>;
// Frontend uses readable name:
function renderUser(user: GetUserOutput) { /* ... */ }
Create /src/api/types.ts exporting all the procedure I/O types. One central place.
4. Massive type unions
Your 80-field UserSettings. Two issues:
- 80 optional fields = 2^80 possible combinations
- Some combinations are invalid but type doesn't enforce
Refactor:
// Identify cohesive sub-types
type NotificationSettings = { /* 12 fields */ };
type DisplaySettings = { /* 8 fields */ };
type PrivacySettings = { /* 6 fields */ };
type IntegrationSettings = { /* 15 fields */ };
// ...
type UserSettings = {
notifications: NotificationSettings;
display: DisplaySettings;
privacy: PrivacySettings;
integrations: IntegrationSettings;
// grouped logically
};
Nested but each piece is comprehensible.
5. Type assertions in tests
// BAD
const mockUser = { id: '123', email: 'test@example.com' } as User;
// BETTER (factory function)
function buildUser(overrides: Partial<User> = {}): User {
return {
id: 'user_test',
email: 'test@example.com',
role: 'user',
createdAt: new Date(),
...overrides,
};
}
// Even better: use zod schema to generate test fixtures
const mockUser = UserSchema.parse({
id: 'user_test',
email: 'test@example.com',
role: 'user',
createdAt: new Date(),
});
Factories are reusable; assertions are duplicated.
API Type Design (boundaries)
For your tRPC API (~80 procedures):
File structure
/src/api/
router.ts (tRPC router)
procedures/
user/
get.ts (single procedure)
create.ts
update.ts
types.ts (exported types per procedure)
schemas.ts (zod schemas, single source of truth)
Per-procedure pattern
// /src/api/procedures/user/create.ts
import { z } from 'zod';
import { router, publicProcedure } from '../../trpc';
export const CreateUserInputSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['admin', 'user', 'guest']),
});
export const CreateUserOutputSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.date(),
});
export type CreateUserInput = z.infer<typeof CreateUserInputSchema>;
export type CreateUserOutput = z.infer<typeof CreateUserOutputSchema>;
export const createUser = publicProcedure
.input(CreateUserInputSchema)
.output(CreateUserOutputSchema) // VALIDATES OUTPUT — catches DB drift
.mutation(async ({ input, ctx }) => {
const user = await ctx.db.users.create({ ... });
return user; // validated against CreateUserOutputSchema
});
Adding output(schema) to tRPC procedures is the runtime-validation fix for your DB-drift bugs.
Runtime Validation Integration
zod is already in your stack. Extend it to outputs.
For tRPC outputs: add .output(schema) to every procedure. tRPC validates response against schema; throws if mismatch (catches DB drift in dev/staging before customers see it).
For external API responses:
const StripeWebhookSchema = z.object({
id: z.string(),
type: z.string(),
data: z.object({
object: z.unknown(),
}),
});
async function handleWebhook(body: unknown) {
const event = StripeWebhookSchema.parse(body); // throws if Stripe's shape changes
// ...
}
For config files:
const ConfigSchema = z.object({
apiUrl: z.string().url(),
timeoutMs: z.number().min(100).max(60000),
retryAttempts: z.number().int().min(0).max(10),
});
export const config = ConfigSchema.parse(JSON.parse(fs.readFileSync('config.json', 'utf-8')));
Catches misconfigs at startup, not at runtime.
Generic + Conditional Type Guidance
Pay-off generics:
- API utilities (
fetchTyped<T>(url): Promise<T>) - Container types (
Result<T, E>,AsyncIterable<T>) - React hooks (
useQuery<T>(key): { data: T | undefined })
Avoid:
- Generic gymnastics for one-off use
- Conditional types deeper than 2 levels
- Variadic type tuples unless library work
Rule: if you're writing generics, ask 'will this be reused 3+ times?' If no, just inline.
Type Migration Plan
Week 1: Add noUncheckedIndexedAccess: true
- Will surface 20-50 errors
- Fix each — usually 1-line fixes (
if (arr[i])orarr[i]?.foo) - Some will reveal real bugs
Week 2: Audit + eliminate as any
- Categorize the 200 instances
- Fix highest-leverage 50 (test mocks, API casts)
- Add ESLint rule to prevent new ones
Week 3: Build API type aliases
/src/api/types.tswith per-procedure types- Refactor consuming code to use named types
Week 4: Add .output() to tRPC procedures
- Start with 10 most-critical procedures
- Verify dev/staging — any schema drift surfaces?
- Roll to all 80 procedures
Week 5: Refactor UserSettings (the 80-field beast)
- Break into cohesive sub-types
- Migrate consumers (typically straightforward —
.notifications.xinstead of.notificationX)
Week 6: Add exactOptionalPropertyTypes: true
- More errors (cleanup needed)
- Fix
Ongoing:
- New code uses patterns by default
- Quarterly audit for regression
Code Review Checklist
For every PR with TypeScript:
- [ ] No new
as any - [ ] No
as SomeTypewithout comment explaining why - [ ] Public functions have explicit return types
- [ ] zod schemas exist for new external boundaries
- [ ] Discriminated unions used for state-machine types
- [ ] No types deeper than 5 lines (if so, refactor or comment)
- [ ] tsconfig flags not loosened in any subdirectory
- [ ] Tests use factories, not
ascasts
What This Strategy Won't Solve
- Won't catch all bugs. TypeScript types are about shapes, not values.
emailis astring; types don't validate it's actually a valid email. - Won't compensate for poor naming. Types help IF names are clear.
UserDatavsRawUserResponsevsUserDTOneed disciplined naming. - Won't fix slow IDE / TS server. Very complex types tank IDE perf. The 5-line readability rule helps; sometimes you have to simplify or use
// @ts-expect-errorstrategically. - Won't replace runtime validation everywhere. Inside the codebase, types suffice. At boundaries, runtime validation (zod) is non-negotiable.
- Won't make junior engineers TypeScript wizards. Types help once people understand them. Pair with team education.
Key Takeaways
- Add
noUncheckedIndexedAccess: true. Catches index-out-of-bounds bugs at near-zero cost. ~20-50 fixes initially. - Eliminate
as any. 200 instances = 200 bug surfaces. Replace with zod parse, satisfies, or proper types. - Add
.output(schema)to tRPC procedures. Catches DB drift at the API boundary. Eliminates the 'response shape changed' bug class. - Build named API type aliases.
/src/api/types.tsexports per-procedure types. ReplacesAwaited<ReturnType<typeof X>>confusion. - 8 patterns worth knowing: discriminated unions, branded types, zod-derived types, mapped types, template literals, satisfies, overloads, state machines.
- 5 anti-patterns to eliminate:
as any, deep generics, tRPC type churn, massive unions, test assertions.
Common use cases
- Engineer establishing TypeScript standards for a new codebase
- Tech lead auditing types in a 50K+ LOC codebase that has type debt
- Backend engineer designing API types that frontend will consume
- Library author designing public API types that users will compose
- Engineer migrating from JavaScript to TypeScript and unsure of strictness level
- Team where types have become unreadable and engineers avoid working in 'typed' files
Best AI model for this
Claude Opus 4. Type system design needs reasoning about generics, variance, and tradeoffs at the boundary of type+runtime — Claude's depth is unmatched. ChatGPT GPT-5 second-best.
Pro tips
- Strict types at boundaries (API, public functions, exports). Loose types acceptable in deeply-internal code.
- Inferred > explicit when inference is unambiguous. Annotation cost is review cost.
- Discriminated unions over generic unions. `{ type: 'A', a: ... } | { type: 'B', b: ... }` beats `A | B`.
- Branded types for domain primitives. `UserId` and `OrderId` shouldn't be assignable to each other even if both are string.
- satisfies > as. `as` overrides type-checking; `satisfies` validates without widening.
- zod schema → infer type. Single source of truth for runtime + compile-time.
- Avoid types that take >5 lines to read. If you need a comment to explain, the type is fighting you.
Customization tips
- Paste 1-3 actual type problems from your codebase. Specific patterns produce specific recommendations; abstract 'types are messy' produces abstract advice.
- Specify your tsconfig honestly — what flags ARE on, what AREN'T. Migration plan calibrates to your starting point.
- List your runtime validation library (zod, valibot, joi). Patterns differ slightly per library.
- Mention team TS expertise level. Patterns appropriate for senior-heavy team differ from junior-heavy.
- Specify codebase scale. 5K LOC migration differs from 500K LOC migration.
- Use the Library Author Mode variant if you're publishing public NPM types — emphasizes generic flexibility, declaration files, semver-stable types.
Variants
API Type Design Mode
For backend API types consumed by frontend — emphasizes shared types, versioning, zod schemas.
Library Author Mode
For public NPM library types — emphasizes generic flexibility, type narrowing, declaration files.
Migration Mode
For migrating JS → TS or upgrading strictness — incremental staging plan.
Type-Heavy Cleanup Mode
For codebases with unreadable types — audits + refactors complex types into simpler patterns.
Frequently asked questions
How do I use the TypeScript Type System Architect prompt?
Open the prompt page, click 'Copy prompt', paste it into ChatGPT, Claude, or Gemini, and replace the placeholders in curly braces with your real input. The prompt is also launchable directly in each model with one click.
Which AI model works best with TypeScript Type System Architect?
Claude Opus 4. Type system design needs reasoning about generics, variance, and tradeoffs at the boundary of type+runtime — Claude's depth is unmatched. ChatGPT GPT-5 second-best.
Can I customize the TypeScript Type System Architect prompt for my use case?
Yes — every Promptolis Original is designed to be customized. Key levers: Strict types at boundaries (API, public functions, exports). Loose types acceptable in deeply-internal code.; Inferred > explicit when inference is unambiguous. Annotation cost is review cost.
Explore more Originals
Hand-crafted 2026-grade prompts that actually change how you work.
← All Promptolis Originals