BairesDev

TypeScript Generics: Managing Refactor Risk in Shared Code

TypeScript generics keep shared code flexible without sacrificing type safety. Learn the governance decisions that prevent costly refactors at scale.

Last updated:
14 min read
Verified Top Talent

By Pablo Bassil

Software Engineer18 Years of Experience

Pablo is a senior software engineer with 15+ years of experience in full-stack development. He serves as tech lead at Red Ventures, building serverless architectures using React, Go, Node.js, and AWS. Pablo has also worked with Paychex on mobile and web applications.

TypeScript logo with abstract graphics representing generics, reusable code, type safety, and refactoring in shared codebases.

Executive Summary

At the layers multiple teams share, the choice between a generic signature and any compounds, because every caller inherits the ambiguity. It resurfaces as expensive refactors and interfaces nobody trusts enough to change.

Teams use generics to keep shared code flexible while preserving the type relationships that make it safe to modify. Using them consistently at those layers is an engineering standard. TypeScript 6.0 raised the floor by making strict true by default, so type discipline is now what a project opts out of.

Compiler defaults only set that floor.

Holding a line above it takes an explicit escape-hatch policy, review at shared interfaces, and runtime validation at the edges. These are governance decisions most organizations opt into by default instead of by design. Maintaining type safety above that floor is what separates a codebase that stays trustworthy from one that quietly erodes.

Key Points

  • Using any at a shared interface disables compiler checking for every downstream caller. This includes every caller beyond the one that introduced it.
  • Stripe’s first automated pass at 3.7 million lines produced over 97,000 error suppressions. Your deferred type debt has a real, countable size.
  • Your team should treat any at a shared utility the same way it treats a skipped test: a gap that needs sign-off.

The Short Answer

TypeScript generics let one implementation serve many callers while preserving the relationship between the types a caller supplies and the types it returns. Use a generic, declared with angle brackets after the function or type name, when several consumers need the same behavior over different types. Use a concrete type when the logic belongs to one domain.

What Are TypeScript Generics?

TypeScript generics solve a tension that shows up in every large codebase: the code that gets reused the most is the code most likely to lose its type safety. The angle brackets after a function or type name, <T>, where type T is the parameter placeholder, are simply where that type parameter gets declared. Teams use generics precisely to keep that reuse from costing them safety.

Generics in TypeScript are a type parameter mechanism that lets functions, classes and interfaces operate across multiple types while preserving the compile-time relationships between them. Instead of hardcoding a specific type like string or falling back to any, a generic accepts a type parameter as input and carries that value through. When a shared utility, API wrapper, or library interface uses generics, the compiler can check type mismatches at every point where that code is consumed.

Generics in TypeScript are erased during compilation. The compiled JavaScript is identical whether generics are used or not, which makes them a compile-time concern with no runtime cost.

Generic Types Preserve Safety Where Concrete Types Force a Choice

The choice between generic and concrete types is a question of where type checking needs to survive reuse. Both produce identical runtime behavior, so there is no performance dimension to weigh. A concrete type provides full checking for exactly one type, while a generic uses a type parameter T to preserve the relationship across every type it is instantiated with.

When a concrete type sits at a shared layer serving several callers, reuse forces a decision. Teams either duplicate the implementation per type or reach for any. Duplication compounds as usage grows, and any stops the compiler from catching a type error tied to that missing type parameter.

Dimension Generic Types Concrete Types
Reusability High, a single implementation works across multiple types Limited, one implementation per type; reuse across types requires duplication or any
Type Safety Scope Preserves the compile-time relationship between input and output types at every instantiation; a contract, not runtime validation Full checking, but only for the one specified type; reuse elsewhere requires casts or any, which abandon safety
Maintainability High across a large codebase, a type change propagates through one definition where consumers share the same behavior; diverging consumers still break individually Lower across a large codebase, a type change requires updating each implementation that hardcoded it
Flexibility Adapts to new types without code changes Adding a new type requires a new implementation or an unsafe cast
Readability Moderate, requires familiarity with generic syntax; a learning curve for teams High for simple cases, the type is explicit and immediately visible
Runtime Performance Identical, types are erased before the code runs Identical, no runtime difference
Common Use Cases Utility functions, API wrappers, data access, reusable components, library interfaces Domain-specific logic, simple single-type operations, internal functions with a single caller

None of that adds up to a verdict. Concrete types are the right answer for single-use functions and for internal helpers with one caller. Exposure runs the other way at shared layers. A concrete type hardcoded today becomes the refactor target next quarter.

Generics Pay Off Most at the Layers With the Most Consumers

Generics matter the most at the layers the most code depends on. These are also the layers where type-safety failures compound fastest. Five come up consistently.

 

TypeScript type safety diagram showing how compiler checks, shared code, generics, and type propagation help prevent errors, while any types and type casts introduce risks.

  • Shared utility functions: getFirst<T>(arr: T[]): T | undefined is a generic function whose type parameter ties the array’s contents, whether a string, number or object, to its return type, and it appears throughout any large codebase. Without generics, teams duplicate it per type or accept any. Duplication expands an update surface with every new type, while any opens a silent hole where the compiler stops checking.
  • API response wrappers: a generic contract keeps the response shape consistent from the API layer through to every caller, so endpoints returning different shapes still share one definition you define once.
  • Data access layers and data models: Repository<T> is a generic class that separates query logic from return types, so adding an entity of a different type doesn’t require a new data access class. Envelopes like Paginated<T> or AuditRecord<T> create a single shape definition once. Without them, every entity gets a bespoke class duplicating identical query logic.
  • Reusable components: a Table<T> or Select<T> parameterizes its row type, so an onRowSelect handler receives the caller’s own type instead of a widened one. Take any[] and every consuming team re-asserts the type at the call site, leaving the props documenting nothing. The same logic applies to the array of rows a table renders, its shape holds regardless of which data type the caller supplies.
  • Library and SDK interfaces: published for other teams to build on, which makes their signatures a contract, not an implementation detail. One definition serves every data type a consuming team brings. A generic interface is what keeps that contract honest across every team building against it.

API wrappers deserve one caution, because they are where teams most often assume more safety than they have.

Code
1type ApiResponse<T> = { data: T; status: number; error?: string }

This is a compile-time contract, not validation. Generic type information is erased before the code runs, so a service returning a malformed JSON object will satisfy ApiResponse<User> and fail somewhere downstream. Untrusted data needs runtime validation, or unknown plus explicit narrowing, at the point it enters the system.

Codebase Layer Generic Pattern Cost of the Non-Generic Alternative When Generic Is the Right Call
Shared utility functions getFirst<T>(arr: T[]) Duplication per type, or any, both compound as usage grows When it is called with more than one type, or likely will be
API response wrappers ApiResponse<T> Response types cast at each call site; no single definition of the contract When multiple endpoints return different shapes through shared handling
Data access and data models Repository<T>, Paginated<T> One class per entity; new entities require new classes When query logic or envelope shape is identical across entity types
Reusable components Table<T>, Select<T> Props typed as any[]; consuming teams re-assert row types When the component renders more than one data shape
Library and SDK interfaces Generic interfaces and props Consumers cast at every touchpoint; friction on each integration When the interface serves multiple teams or data types

Where Generics Stop Paying

Those five layers share a structural property: many callers, high exposure, expensive changes. Consumer count alone is still a poor decision boundary.

A generic earns its complexity when callers share the same behavior and the same type relationship tied to a given generic parameter. When they only share the shape, abstraction starts absorbing their differences, a constraint here, a conditional type there, an overload for the case that doesn’t fit. The result is a type signature that takes longer to understand than the duplication it replaced.

Repository<T> is a good abstraction while every entity is queried the same way. The moment one entity needs soft deletes and another needs tenant scoping, the generic either sprouts conditional types or leaks exceptions into every caller. In this case, two honest concrete classes are faster to read and cheaper to change.

The real test for leaders setting standards is deciding if the added type-level complexity stays smaller than the duplication it replaces. Consumer count is a weaker proxy that overstates the case for abstraction.

Generics and Codebase Maintainability Over Time

The cost of skipping generics at shared layers doesn’t surface right away. Instead, it shows up the first time a type needs to change across a codebase that hardcodes it everywhere.

Concrete types at shared layers create refactor coupling: when the underlying type changes, every function that hardcodes it becomes a change target. Generics narrow that surface without eliminating it, since changing a generic’s constraint or its public contract can still ripple to every caller. Where consumers have diverged, a single generic definition turns into a single point of breakage instead of a single point of change.

The clearest available evidence covers type discipline broadly, and none of it isolates generics. Stripe moved its Dashboard codebase of 3.7 million lines from Flow to TypeScript in a single pull request, and the first automated pass produced more than 97,000 error suppressions, which iterative work on the conversion tool brought down to roughly 37,000 before the change landed. Stripe put that final figure at about one suppression per thousand lines.

Suppressions accumulate wherever type coverage is deferred under delivery pressure, and the mechanism is size-independent even where the counts are not. A platform team of fifty engineers will never reach 97,000.

On whether that coverage is worth holding, the peer-reviewed evidence is modest but real. A 2017 ICSE study by Zheng Gao, Christian Bird and Earl Barr found that static type checking could have caught roughly 15% of the public JavaScript bugs it examined, all of which had already survived testing and code review. A widely repeated Airbnb figure of 38% comes from a retrospective on six months of internal postmortems, presented at a 2019 conference and reported by InfoQ; it counts high-severity incidents only, so it sits on a different scale.

Neither figure shows that generics caused the outcomes. Both support the narrower premise that type coverage pays, and generics are one mechanism for holding it where code is reused.

The onboarding dimension is quieter but real. A new engineer who sees Repository<User> knows right away that the pattern covers every entity, a small but concrete example of how generic naming communicates intent. Custom per-entity classes require a fresh mental model for each one.

The presence of any at a shared layer is worth investigating before treating it as a defect. It sometimes reflects a deliberate call. Auditing every instance burns review attention. The narrower problem is any at an interface many teams consume lands every downstream caller outside the compiler’s reach with nobody owning that decision.

Establishing these patterns during initial architecture is cheaper than retrofitting them later. Our TypeScript development services teams work most often on the harder version: strict-mode adoption in an existing codebase, shared API contract design and platform standardization where the abstractions were never agreed on. The gap widens with every team already integrated against the old interface.

Setting Engineering Standards for Generic Discipline

Knowing that generics matter at shared layers is not truly useful until it becomes a standard reviewers enforce and new engineers inherit. That standard has four layers, and only the first is automatic. Whether the shared code in question is a generic function, a generic class, or a generic interface, the same four layers decide whether that guarantee holds.

Layer What It Covers What It Leaves Open Where It Lives
Compiler strictness Inferred any, null handling, unchecked index access Every any written deliberately tsconfig.json, enforced in CI
Escape-hatch policy When explicit any and assertions are acceptable, and who signs off Nothing, if unwritten A written engineering standard
Review at shared interfaces utility, API, data access, component and library signatures Code below the shared boundary ownership rules on shared paths
Runtime validation External payloads that erased generic contracts cannot check Data already inside the type system Schema validators at ingress

Start with scope. The four layers above are where generic patterns should be expected; domain-specific logic and single-caller internal functions fall outside that scope.

The dividing line is reuse. In practice, any in a shared utility or API layer should draw the same review scrutiny as a skipped test, while any in an internal, single-use function is an acceptable pragmatic choice.

Code
1// Before
2function getFirst(arr: any[]): any
3// After
4function getFirst<T>(arr: T[]): T | undefined

The first signature compiles and tells the compiler nothing: any array in, any value out, no relationship between them. The second preserves that relationship, and the | undefined is the honest part, because a signature promising T would be lying to every caller of an empty array that returns no value. TypeScript’s noUncheckedIndexedAccess surfaces this class of gap.

Compiler configuration is the cheapest of the four layers, and its baseline recently moved. TypeScript 6.0 made strict true by default, and 7.0, released on July 8, 2026, turned 6.0’s deprecations into hard errors.

That does not make strict mode free. The 7.0 port of the compiler to Go removed the build-time objection, with Microsoft reporting full-build speedups typically between 8x and 12x. It did nothing about the cost that actually deters teams: remediating the wave of errors an existing codebase inherits on upgrade.

Managing that cost is what the remaining three layers are for.

Compiler flags only cover part of the ground. noImplicitAny catches the any TypeScript infers and does nothing about the any a developer writes deliberately. That gap is where policy, review, and runtime validation live.

Notion’s engineering team built a ratcheting system of custom ESLint rules that counts existing violations and refuses to let the number grow. Fixing an error lowers the allowed count automatically, while adding one blocks the merge. They used it to eliminate unsafe type assertions without freezing feature work for a migration.

That standard survives a delivery schedule by managing the trend line rather than judging individual pull requests. Of these examples, it is also the closest to the scale most platform teams operate at.

Central defaults hold at much larger scale, through a different mechanism. In a 2020 engineering post, Bloomberg described defaulting to strict mode across its internal TypeScript platform, reporting more than 200 projects that opted into TypeScript in the first year with none reverting. That platform spans roughly 2,000 JavaScript engineers and 50 million lines of code.

Few organizations operate at that size. The transferable part is the governance choice: the default was set centrally, not negotiated team by team.

Type discipline is either an enforced standard or an accumulating accident, and most organizations make that choice by default. The default just changed under them. Teams that don’t own these four layers of generic discipline carry mounting refactor risk and a trail of type debt that never ends.

Frequently Asked Questions

  • What Do Generics in TypeScript Actually Do?

    Generics let functions, including arrow functions, classes and interfaces work across multiple types while preserving the compile-time relationship between the types a caller supplies and receives. A generic carries the caller’s type through; any disables checking entirely.

  • When Should You Use Generics vs. Concrete Types?

    Use generics when multiple callers need the same behavior over different types and the type relationship between input and output matters. Use concrete types when the logic belongs to one domain with one caller. If your consumers share only the shape but diverge in behavior, duplication is usually cheaper. A generic that sprouts conditional types to handle one exception has already stopped paying for itself.

  • When Do Generics Become Over-Engineering?

    When the signature takes longer to understand than the duplication it replaced, you’ve crossed the line. The clearest signal is conditional types or layered constraints added to handle one consumer’s edge case. At that point the abstraction is absorbing differences it wasn’t designed for. Two honest concrete classes are faster to read and cheaper to change than a generic that’s been bent to fit.

  • How Should Teams Handle Untrusted API Data at Runtime?

    Validate at the boundary before you assign a type. Generic type information is erased at compile time, so ApiResponse<User> won’t catch a malformed payload from an external service. The payload will satisfy the type and fail somewhere downstream. Use unknown with explicit narrowing, or a schema validator like Zod, at the point external data enters your system. That’s where the contract actually gets enforced.

  • What Standards Should Platform Teams Enforce Around any?

    You need four layers. Compiler strictness is the floor. noImplicitAny only catches any that TypeScript infers, so it does not cover the any your team writes deliberately. Your second layer is a written policy on when explicit any is acceptable and who signs off. Third, code review at shared interfaces should flag any the same way it flags a skipped test. Fourth, runtime validation at external boundaries handles what erased generic contracts cannot. Most teams only have the first layer and assume it’s enough.

  • Do Generics Affect Runtime Performance?

    No. Generics are erased during compilation and the output JavaScript is identical to what you’d get without them. There’s no runtime overhead whether you’re using a generic function, class, or interface. This is a compile-time tool only, so the tradeoffs you’re weighing are about readability and maintainability, not performance.

By Pablo Bassil

Software Engineer18 Years of Experience

Pablo is a senior software engineer with 15+ years of experience in full-stack development. He serves as tech lead at Red Ventures, building serverless architectures using React, Go, Node.js, and AWS. Pablo has also worked with Paychex on mobile and web applications.