Home / Articles / TypeScript is not slow—over-engineered types are

This article is published in English.

TypeScript is not slow—over-engineered types are

Generic soup, premature DRY on types, optional-flag state, and type-level cleverness hurt velocity. Prefer simple interfaces, discriminated unions, and measured tsc costs.

2143 words

How generic soup, conditional-type gymnastics, and premature abstraction choke delivery speed.

In a sprint retrospective a junior engineer admits that adding one optional field to an existing API payload took four hours. The IDE reveals why: an interface built from nested conditionals.

type ExtractNestedPayload<
  T,
  K extends keyof T,
  U extends boolean = false
> =
  T[K] extends (...args: any[]) => infer R
    ? R extends Promise<infer P>
      ? U extends true ? NonNullable<P> : P
      : R
    : T[K] extends Array<infer Item>
      ? Item
      : never;

Four layers of nested conditionals, three generic parameters, and a ternary chain that needs a whiteboard. Compiler errors arrive as long red tooltips:

Type 'ExtractNestedPayload<ApiResponses["users"], "data", true>'
is not assignable to type 'UserDTO'.
Types of property 'meta' are incompatible.Type 'unknown' is not assignable to type
'{ pagination: PaginationMeta }'.

The familiar complaint follows: TypeScript itself is slowing the team. Usually it is not. Over-engineered abstractions are. TypeScript was meant as a pragmatic guardrail—fewer runtime crashes, better autocomplete. Somewhere along the way many codebases turned it into a puzzle: hours spent on mathematically “perfect” generics to avoid a few lines of plain declarations. The more ornate the type graph becomes, the less it helps the humans who ship features. A type should communicate intent. If reading it requires conditional types, inference quirks, mapped types, distributive unions, recursive generics, and a chain of utilities, intent is gone and another system must be debugged. Below are patterns that backfire in production and cleaner alternatives that restore velocity.

1. The generic soup anti-pattern

Generics power Promise<T>, Array<T>, and Map<K, V>. Trouble starts when flexibility becomes a seniority badge. More generic is not automatically better. Consider a table component:

// The Generic Soup Nightmare
interface TableProps<
  TData,
  TKey extends keyof TData,
  TColumn extends ColumnDef<TData, any>,
  TFilter extends Record<string, any> = Record<string, any>
> {
  data: TData[];
  keyExtractor: (item: TData) => TData[TKey];
  columns: TColumn[];
  initialFilter?: TFilter;
  onRowClick?: (row: TData) => void;
}

It looks infinitely reusable: data shape, row key, columns, filters. Every abstraction carries cognitive cost. Call sites force the compiler to infer several interrelated parameters. A small prop mismatch may not point at the offending property; inference can collapse across the whole graph. A new teammate must learn why TKey exists, why it extends keyof TData, how column and filter generics interact, and what the compiler actually inferred. That is a heavy tax for a table.

That tax shows up as slower code review, longer onboarding, and a culture where only one or two people dare to change shared UI primitives. Velocity loss is social as much as technical: teammates stop proposing small improvements because they fear the type fallout. When a table abstraction needs a design doc to explain its generics, the abstraction has grown past the problem it was meant to solve.

Production symptoms are boring and expensive. A one-line prop rename triggers diagnostics that mention unrelated type parameters. Autocomplete stalls while the language service re-evaluates the generic graph. CI typecheck minutes creep upward without anyone shipping a clearer domain model. None of those costs appear in a benchmark of “TypeScript vs JavaScript”; they appear in calendar time.

The concrete alternative

Prefer one data parameter and simple supporting interfaces:

// Simple, readable, and instant compiler diagnostics
interface TableColumn<T> {
  header: string;
  accessor: (item: T) => React.ReactNode;
  width?: string;
}
interface DataTableProps<T> {
  data: T[];
  columns: TableColumn<T>[];
  rowKey: (item: T) => string;
}

The component stays reusable without becoming a type oracle. Reusable does not mean infinitely generic.

Reusable does not mean infinitely generic.

Teams sometimes fear that simplifying generics will force copy-paste. In practice, two or three focused table variants with clear props beat one universal component that nobody can instantiate without trial and error. Share the rendering helpers and CSS; keep the public props boring. The compiler then points at the exact mismatched field instead of collapsing four inference variables into an unknown rabbit hole.

2. Premature DRY in type definitions

“Don’t Repeat Yourself” is useful for runtime code and dangerous when applied blindly to types. Seeing similar fields, teams derive one type from another:

// Over-abstracted type derivation
type RegisterFormValues =
  Omit<
    UserProfile,
    'id' | 'createdAt' | 'updatedAt' | 'role'
  > & {
    passwordConfirmation: string;
    termsAccepted: boolean;
  };

Later the entity changes:

interface UserProfile {
  // ...
  phoneNumber: string; // now required!
}

The derived form type silently inherits a required phoneNumber that the registration flow never wanted. More surgery follows:

type RegisterFormValues =
  Omit<
    UserProfile,
    'id' |
    'createdAt' |
    'updatedAt' |
    'role' |
    'phoneNumber'
  > & {
    phoneNumber?: string;
    passwordConfirmation: string;
    termsAccepted: boolean;
  };

Each omission compounds opacity. The form and the database entity change for different reasons; coupling them creates surprise breakages.

Duplication is cheaper than the wrong abstraction

Spell the contracts separately:

// Database Entity Contract
export interface UserProfile {
  id: string;
  email: string;
  fullName: string;
  phoneNumber: string;
  createdAt: string;
}
// Registration Form Contract
export interface RegisterFormValues {
  email: string;
  fullName: string;
  phoneNumber?: string;
  password: string;
  passwordConfirmation: string;
  termsAccepted: boolean;
}

A few duplicated fields cost less than a fragile derivation graph. If two types change for different reasons, they probably should not be coupled.

If two types change for different reasons, they probably should not be coupled.

A useful smell test: would a product manager describe these as the same concept? A user profile row in storage and a registration form on a marketing page rarely share lifecycle, validation rules, or ownership. When they drift, derived types amplify the drift into compile errors far from the edit that caused them. Explicit interfaces make the drift visible and local. Mapping helpers—small functions from entity to form defaults—keep runtime conversion honest without marrying the type identities forever.

3. Discriminated unions beat optional property spaghetti

Async UI state often looks like this:

// The "Optional Flag" Anti-Pattern
interface RequestState<T> {
  isLoading: boolean;
  isSuccess: boolean;
  isError: boolean;
  data?: T;
  error?: Error;
}

Consumers then invent illegal combinations—isSuccess with missing data, or isLoading with an error still set:

if (state.isSuccess && state.data) {
  return <div>{state.data.name}</div>;
}

Optional flags do not encode a state machine; they encode hope.

The power of discriminated unions

Make status explicit:

export type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

Rendering becomes exhaustive and safe:

function RenderProfile({
  state
}: {
  state: AsyncState<UserProfile>;
}) {
  switch (state.status) {
    case 'idle':
      return <div>Ready to load profile.</div>;
    case 'loading':
      return <LoadingSpinner />;    case 'error':
      return <ErrorMessage error={state.error} />;    case 'success':
      // TypeScript guarantees state.data exists here!
      return <h1>Welcome, {state.data.fullName}</h1>;
  }
}

Impossible states disappear from the type, so many defensive checks vanish from the UI.

Impossible states disappear from the type, so many defensive checks vanish from the UI.

Optional-flag models also confuse analytics and logging. Was the request successful if isSuccess is true but data is undefined? Discriminated unions force that question to be answered when the state is constructed, not when a junior engineer guesses in JSX. Reducers and async wrappers become clearer too: each transition returns a complete variant instead of toggling booleans that can disagree.

4. The any vs unknown fallacy

Using any to silence the compiler deletes the benefit of TypeScript at the boundary. Prefer unknown and narrow with guards when reading API payloads or localStorage.

Replace any with unknown + type guards

// Safe parsing of unknown API or localStorage data
function parseStoredPreferences(
  raw: unknown
): UserPreferences {
  if (
    typeof raw === 'object' &&
    raw !== null &&
    'theme' in raw &&
    (raw.theme === 'light' || raw.theme === 'dark')
  ) {
    return {
      theme: raw.theme,
      fontSize:
        typeof (raw as any).fontSize === 'number'
          ? (raw as any).fontSize
          : 14,
    };
  }
  // Safe fallback default
  return {
    theme: 'dark',
    fontSize: 14
  };
}

External data stays untrusted until validated; internal code then receives a concrete shape.

External data stays untrusted until validated; internal code then receives a concrete shape.

any is especially damaging at module boundaries because it poisons inference downstream: one any from JSON.parse can erase checks across an entire feature. unknown stops the poison at the door. Pair it with schema validation libraries when payloads are large, or with narrow hand-written guards when shapes are small and stable. Either way, the domain core should see only validated types.

5. Measure typecheck time in CI

When the editor feels slow, measure before blaming the language:

npx tsc --noEmit --extendedDiagnostics

Watch instantiation counts, files, and time. Expensive recursive conditionals often dominate. If the type system is costly to compile, it should justify that cost.

If the type system is costly to compile, it should justify that cost.

Extended diagnostics often reveal a handful of files inventing most instantiations—frequently recursive conditional utilities imported for convenience. Deleting or simplifying those hubs can reclaim minutes per CI run. Track the number over time the same way you track bundle size. A type architecture that cannot explain its compile cost will eventually be blamed on “TypeScript is slow,” and the team will underinvest in the language that still protects them at runtime.

6. The problem with type-level cleverness

Cleverness is another trap: types that derive entire API surfaces from other types, then grow more conditionals, mapped types, and recursion until nobody will touch them. Capability is not a reason to use the heaviest feature.

Compare a direct indexed access:

type UserName = UserProfile['fullName'];

with a recursive utility that hunts every string-valued property in an arbitrary object. If the product only needs UserProfile['fullName'], the machinery adds risk. Good engineering picks the smallest clear tool. A type six months later should explain itself; “do not touch this” means the abstraction already failed.

7. A pragmatic TypeScript manifesto

1. Write types for humans first, the compiler second

If teammates cannot understand a definition in under a minute, simplify. Elegance that only the author follows is debt.

2. Prefer duplication over premature coupling

Do not warp a component interface just to reuse three fields from an unrelated entity. Separate responsibilities, separate types.

3. Use discriminated unions for state

Encode real state machines with a status discriminant so the compiler removes impossible branches.

4. Never let generics exceed two parameters

Three or more generics usually mean the abstraction is too wide. Split it. This is a heuristic, not a law—when relationships are hard to explain, revisit the design.

5. Treat external data as unknown

API responses, storage, third-party payloads, and user input should be validated at the boundary before becoming trusted domain objects.

6. Measure before blaming TypeScript

Slow CI or a lagging language service often reflects type architecture, not the language brand. Profile, then simplify the hot types.

TypeScript should make code boring

TypeScript is at its best when it stays out of the way: autocomplete, safe refactors, fewer runtime surprises. It should not feel like a puzzle on every component change. The least impressive interfaces—plain business objects—are often the most valuable. Keep types simple, practical, and tied to real product concepts so the team ships instead of decoding type algebra.

Keep types simple, practical, and tied to real product concepts so the team ships instead of decoding type algebra.

Retrospectives improve when the conversation shifts from language brand to design choices: how many generics, how much derivation, how honest the state machine is, how external data enters the app. TypeScript rewards that honesty with faster feedback on the changes that matter. It punishes cleverness with opaque errors. Choose the boring path on purpose, document the few advanced types that are truly load-bearing, and leave the puzzle-contest patterns out of the shared libraries your newest engineers must touch on day one.

When a change feels blocked by types, ask whether the model reflects the product. Often the fix is not a deeper conditional—it is a clearer interface, a split module, or a union that names the states you already discuss in stand-up. That is how TypeScript stops being overhead and returns to being leverage.