How to avoid any without duplicating code
Using any in TypeScript buys short-term speed and gives up type safety for good. Writing a separate function for every type solves that, but produces duplication and maintenance overhead instead. Generics resolve this dilemma: a single function, interface, or class stays reusable for any type while keeping the compiler's full type checking intact.
Table of Contents
- 1. The problem: any or code duplication
- 2. Generic functions: syntax and type inference
- 3. Multiple type parameters: K, V, and constraints
- 4. Generic interfaces and type aliases
- 5. Generic classes: state containers and typed collections
- 6. Practical example part 1: types for the API fetch wrapper
- 7. Practical example part 2: the generic fetch function
- 8. Naming conventions: T, K, V or descriptive names
- 9. Generics compared: when they pay off and when they are overkill
- 10. Summary
- 11. FAQ
1. The problem: any or code duplication
any is the fastest way to silence TypeScript while giving up every benefit of its type system at the same time. As soon as a variable or function parameter is declared as any, the compiler switches off type checking entirely for anything that depends on that value. Autocomplete disappears, typos in property names only surface at runtime, and refactoring tools can no longer reliably tell which parts of the code are affected by a change. any behaves like a type but acts like the complete absence of one.
The obvious alternative, writing a separate function for every concrete type, such as getFirstString and getFirstNumber instead of one shared function, solves the type safety problem but creates a new one: code duplication. Every newly supported shape means another nearly identical function, another test case, another spot that has to be kept in sync during a bug fix. In larger codebases this duplication quickly grows into dozens of variants of the same logic.
Generics resolve exactly this dilemma. Instead of hardcoding a concrete type, the type itself becomes a parameter of the function, interface, or class. The compiler knows the actual type at the call site, checks it fully, and derives return types and allowed operations from it, without a single line of logic being duplicated.
2. Generic functions: syntax and type inference
The basic syntax of a generic function inserts angle brackets with a type placeholder before the parameter list, as in function identity<T>(value: T): T. T is not a concrete type but a variable at the type level, rebound by the compiler on every call. Calling identity(42) makes T infer to number, while identity('text') makes T infer to string. The return type stays exactly coupled to the input type, something any could never achieve.
This automatic derivation is called type inference and works without an explicit type argument in most cases. Only when the compiler cannot unambiguously derive the type from the arguments, for example with an empty array, do you supply the type argument explicitly: createArray<string>(). This explicit form also documents intent when it would otherwise be unclear from the code.
// Generic identity function preserves the exact input type
function identity<T>(value: T): T {
return value;
}
const num = identity(42); // inferred as number
const str = identity("hello"); // inferred as string
// Generic helper: wrap a value in a fixed-length array
function createArray<T>(item: T, length: number): T[] {
return Array.from({ length }, () => item);
}
const zeros = createArray<number>(0, 3); // number[]
const flags = createArray(false, 2); // inferred as boolean[]
// Without generics, this would need one function per type
// or would fall back to "any" and lose all type information
3. Multiple type parameters: K, V, and constraints
Generic functions are not limited to a single type parameter. For key-value shapes, two parameters are common, usually K for the key type and V for the value type, as in function toMap<K, V>(entries: [K, V][]): Map<K, V>. Each parameter is inferred independently from the arguments, so a call with string keys and number values automatically yields Map<string, number>.
It becomes especially useful when one type parameter depends on another. With K extends keyof T you can enforce that K may only ever be a property name that actually exists on T. A function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] then returns exactly the type that property actually has, and the compiler rejects calls with nonexistent keys at compile time instead of surprising you with undefined at runtime.
// Multiple type parameters with a constraint between them
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
interface Product {
id: number;
name: string;
price: number;
}
const product: Product = { id: 1, name: "Keyboard", price: 79 };
const name = getProperty(product, "name"); // inferred as string
const price = getProperty(product, "price"); // inferred as number
// getProperty(product, "sku"); // compile error: "sku" is not a key of Product
// Two independent type parameters for key/value pairs
function toMap<K, V>(entries: Array<[K, V]>): Map<K, V> {
return new Map(entries);
}
const stock = toMap([["sku-1", 12], ["sku-2", 4]]); // Map<string, number>
4. Generic interfaces and type aliases
Not just functions, interfaces and type aliases can be generic too. An interface like interface Container<T> { value: T } describes a shape whose concrete content is only decided at the point of use: Container<string> for text content, Container<Product> for product data, without needing a separate interface for every case. This is especially valuable for recurring wrapper shapes like API responses, which always share the same envelope but carry different payloads.
Type aliases with generic parameters suit more compact constructs, such as type Nullable<T> = T | null or type Pair<A, B> = [A, B]. The key difference from interfaces is that type aliases can also generically describe union types and tuples, while interfaces stay limited to object shapes. In practice you combine both depending on the structure: interfaces for objects with clear property names, type aliases for anything beyond a plain object shape.
// Generic interface: reusable envelope for any payload type
interface ApiResponse<T> {
data: T;
status: number;
timestamp: string;
}
// Generic type alias: nullable value
type Nullable<T> = T | null;
// Generic type alias: tuple pair
type Pair<A, B> = [A, B];
interface User {
id: number;
email: string;
}
const userResponse: ApiResponse<User> = {
data: { id: 1, email: "dev@mironsoft.de" },
status: 200,
timestamp: "2026-07-12T09:00:00Z",
};
const maybeUser: Nullable<User> = null;
const entry: Pair<string, number> = ["sku-1", 12];
5. Generic classes: state containers and typed collections
Classes can carry the same type parameter as functions and interfaces, which makes them ideal for reusable data structures. A class Stack<T> with methods push(item: T): void and pop(): T | undefined stays usable for any concrete type: Stack<string> for an undo history of text commands, Stack<Product> for recently viewed products, without needing to modify the class itself. The type parameter is fixed at instantiation, as in new Stack<Product>(), and remains locked for the entire lifetime of the instance.
The same principle carries state containers in frontend applications: a generic Store<T> wraps state of any type together with getters, setters, and a subscription mechanism, without needing a dedicated store for every data type. It matters not to make the type parameter too broad: a Stack<unknown> avoids errors caused by any, but forces a manual type check on every pop() before the value can be used, because unknown, unlike a concrete generic parameter, permits no operations without a prior check.
6. Practical example part 1: types for the API fetch wrapper
The native fetch in browser and Node APIs returns Promise<any> from response.json() by design, since it's impossible at compile time to know what shape any given endpoint actually returns. This is exactly where generics prove their practical value: a wrapper around fetch can accept the expected response type as a type parameter and restore type checking at every call site, without needing a dedicated fetch function per endpoint.
Before writing the function itself, it's worth defining the supporting types: a generic result envelope, a unified error type, and optional request parameters. These types form the foundation the actual generic fetch function builds on in the next section, and they can be reused independently of any specific endpoint.
// Shared error shape for every API call in the application
interface ApiError {
code: string;
message: string;
}
// Generic result type: either a typed success or a typed error
type FetchResult<T> =
| { ok: true; data: T }
| { ok: false; error: ApiError };
// Optional request configuration, independent of the response type
interface FetchOptions {
method?: "GET" | "POST" | "PUT" | "DELETE";
headers?: Record<string, string>;
body?: unknown;
}
7. Practical example part 2: the generic fetch function
With the types from the previous section in place, the generic fetch function itself can be written compactly. The type parameter T describes only the expected success payload shape, while error cases are represented by the fixed ApiError type. Inside the function, exactly one deliberate type assertion remains necessary, because the compiler cannot know that arbitrary JSON actually has the shape T. This assumption is made at exactly one spot instead of spreading uncontrolled across the whole codebase.
At the call site, the effort pays off: typedFetch<Product>('/api/products/1') returns a fully typed FetchResult<Product>, including autocomplete for result.data.price and a compiler-enforced check of result.ok before data can be accessed. A second call with a different type parameter, such as typedFetch<User[]>('/api/users'), reuses the exact same implementation without duplicating a single line.
// Generic fetch wrapper: T is only the success payload shape
async function typedFetch<T>(
url: string,
options: FetchOptions = {}
): Promise<FetchResult<T>> {
try {
const response = await fetch(url, {
method: options.method ?? "GET",
headers: options.headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
return { ok: false, error: { code: String(response.status), message: response.statusText } };
}
// Single, deliberate assertion: the JSON shape is trusted here
const data = (await response.json()) as T;
return { ok: true, data };
} catch (err) {
return { ok: false, error: { code: "NETWORK_ERROR", message: String(err) } };
}
}
// Call sites reuse the same function for completely different shapes
const productResult = await typedFetch<Product>("/api/products/1");
if (productResult.ok) {
console.log(productResult.data.price); // fully typed, autocompletes
}
const usersResult = await typedFetch<User[]>("/api/users");
8. Naming conventions: T, K, V or descriptive names
The convention of naming type parameters with single uppercase letters like T, K, V comes from generic programming languages such as C++ and Java and has proven itself in TypeScript for simple cases. T traditionally stands for Type, K for Key, V for Value, E for Element. For functions with one or two type parameters and an obvious context, such as identity<T> or Map<K, V>, this short form is more readable than a spelled-out name, because it's instantly recognizable as a type parameter rather than a concrete type.
Once a function has three or more type parameters, or their meaning isn't obvious from the immediate context, descriptive names like TItem, TResponse, or TFormValues noticeably improve readability. A leading T prefix still signals that it's a type parameter rather than a concrete type name, without colliding with interfaces or classes in the same namespace. The rule of thumb: short forms for generic, algorithmic utilities, descriptive names for domain-specific structures with several type parameters active at once.
9. Generics compared: when they pay off and when they are overkill
Not every function benefits from a type parameter. The table below shows typical situations where moving from any or duplicated code to generics makes a clear difference, and where a simple concrete type is enough.
| Scenario | Without generics | With generics | Benefit |
|---|---|---|---|
| Function for one type | firstString(arr: string[]): string |
first<T>(arr: T[]): T |
One implementation for every array type |
| Duplicated utility functions | getFirstString, getFirstNumber, … |
first<T>(arr: T[]): T |
No duplication, one test case |
| Processing an API response | fetch(url).then(r => r.json()) as any |
typedFetch<Product>(url) |
Type safety all the way to the call site |
| Reading an object property | obj[key] with key: string |
getProperty<T, K extends keyof T> |
Compiler rejects nonexistent keys |
| Container for one fixed type | class ProductStack (duplicated per type) |
class Stack<T> |
One class for Stack<Product>, Stack<string>, etc. |
Generics pay off as soon as the same code path needs to be reused for more than one type, for example in utility functions, generic containers, or exactly the kind of fetch wrapper shown above. They are overkill, on the other hand, when a function is demonstrably called with only a single concrete type, and stays that way: a type parameter instantiated everywhere with the same argument adds complexity without covering a single additional use case. When in doubt, write the concrete type first, and only generalize to a generic on the second genuine reuse.
Mironsoft
TypeScript tooling, type-safe frontend architecture, and Magento/Hyvä integrations
Need a type-safe TypeScript architecture for your project?
We audit existing TypeScript code, replace any and duplicated logic with clean generics, and build type-safe API layers for Magento and Hyvä projects that stay maintainable as complexity grows.
Code review
Auditing existing TypeScript types for any usage and avoidable duplication
Generic refactoring
Introducing reusable, type-safe functions, interfaces, and classes
API layer
Typed fetch wrappers and service layers for Magento and headless projects
10. Summary
Generics fundamentals in TypeScript solve a recurring dilemma: any gives up type safety, duplicating code per type creates maintenance overhead. Generic functions, interfaces, and classes replace both approaches with a single, type-parameterized implementation the compiler checks against the concrete type on every call. Multiple type parameters with constraints like K extends keyof T allow relationships between types that would be impossible with any or fixed types. The typed API fetch wrapper shows how this principle applies to an everyday practical problem: one function, any number of response types, full type checking at every call site.
The T, K, V naming convention remains the most readable choice for simple, algorithmic utilities, while descriptive names like TItem or TResponse improve clarity for more complex, domain-specific structures with several active type parameters. Generics are not an end in themselves: they pay off once code is reused for more than one type, and become overkill when a concrete type is sufficient indefinitely. Keeping that boundary in view produces TypeScript code that stays both type-safe and genuinely readable.
Generics Fundamentals - The Essentials at a Glance
any vs. generics
any switches off type checking entirely. Generics keep full type safety while staying reusable across any type.
Multiple type parameters
<K, V> for key-value shapes, K extends keyof T for constraints between type parameters.
Interfaces & classes
Generic interfaces for recurring wrapper shapes, generic classes for typed containers and stores.
Naming conventions
T/K/V for simple utilities, descriptive names like TItem for complex, domain-specific structures.