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.
- 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.
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.
1// Before
2function getFirst(arr: any[]): any
3// After
4function getFirst<T>(arr: T[]): T | undefinedThe 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.


