Why “Any” Isn’t Good Enough

Imagine you need to build a reusable collection type — a wrapper around JavaScript’s native array. You want it to work for numbers, strings, user objects, or anything else a team might throw at it. Without generics, the type system gets in the way immediately.

One option is to duplicate the collection for every type you want to support. That means copying the same logic, the same buffer handling, the same iteration code, over and over. It breaks the DRY principle and turns maintenance into a nightmare. A change to the core implementation has to be manually propagated across every typed copy, including user-defined types you can’t even anticipate.

const intList = IntegerList.create();
intList.add(4);

const stringList = StringList.create();
stringList.add('hello');

const userList = UserList.create();
userList.add(new User('Jamie'));

Another approach is to fall back to any, which is conceptually similar to relying on a root supertype in an inheritance hierarchy. In C#, all types derive from System.Object; in TypeScript, any sidesteps type checking entirely. That gives you one implementation that accepts anything:

const intList = AnyList.create();
intList.add(4);

const stringList = AnyList.create();
stringList.add('hello');

const userList = AnyList.create();
userList.add(new User('Jamie'));
typescript // Implementation using any
class AnyList {
    private values: any[] = [];

    private constructor (values: any[]) {
        this.values = values;

        // Some more construction work.
    }

    public add(value: any): void {
        this.values.push(value);
    }

    public where(predicate: (value: any) => boolean): AnyList {
        return AnyList.from(this.values.filter(predicate));
    }

    public select(selector: (value: any) => any): AnyList {
        return AnyList.from(this.values.map(selector));
    }

    public toArray(): any[] {
        return this.values;
    }

    public static from(values: any[]): AnyList {
        // Perhaps we perform some logic here.
        // ...
    
        return new AnyList(values);
    }

    public static create(values?: any[]): AnyList {
        return new AnyList(values ?? []);
    }

    // Other collection functions.
    // ...
}

But any has a serious downside: TypeScript knows nothing about the elements inside the collection. It can’t verify that you’re calling existing methods or accessing real properties, and it won’t help you inside mapping or filtering functions. Mistakes like referencing a non-existent property or calling a function that doesn’t exist will slip straight through to runtime:

// Providing seed data.
const userList = AnyList.create([new User('Jamie')]);

// This is fine and expected.
userList.add(new User('Tom'));
userList.add(new User('Alice'));

// This is an acceptable input to the TS Compiler,
// but it’s not what we want. We’ll definitely
// be surprised later to find strings in a list
// of users.
userList.add('Hello, World!');

// Also acceptable. We have a large tuple
// at this point rather than a homogeneous array.
userList.add(0);

// This compiles just fine despite the spelling mistake (extra 's'):
// The type of `users` is any.
const users = userList.where(user => user.getNames() === 'Jamie');

// Property `ssn` doesn’t even exist on a `user`, yet it compiles.
users.toArray()[0].ssn = '000000000';

// `usersWithId` is, again, just `any`.
const usersWithId = userList.select(user => ({
    id: newUuid(),
    name: user.getName()
}));

// Oops, it’s "id" not "ID", but TS doesn’t help us. 
// We compile just fine.
console.log(usersWithId.toArray()[0].ID);

You get one reusable data structure, but you lose all compile-time safety. The language can’t tell you anything about the contents of the array, so you’re back to debugging runtime errors that a statically typed language was supposed to prevent.

Generics as Type Placeholders

Generics solve that dilemma. Rather than hard-coding a concrete type when you define a class or function, you defer the decision until the type is actually known by the consumer. Think of a generic type parameter as a placeholder or variable — but for types — that gets filled in later.

That means you can write a data structure once and have it operate safely on any type you choose when you instantiate it. The earlier attempt with any achieved flexibility, but generics preserve flexibility while maintaining type safety.

Here’s the same collection structure implemented with generics, where T is the type parameter the consumer supplies:

class TypedList<T> {
    private values: T[] = [];

    private constructor (values: T[]) {
        this.values = values;
    }

    public add(value: T): void {
        this.values.push(value);
    }

    public where(predicate: (value: T) => boolean): TypedList<T> {
        return TypedList.from<T>(this.values.filter(predicate));
    }

    public select<U>(selector: (value: T) => U): TypedList<U> {
        return TypedList.from<U>(this.values.map(selector));
    }

    public toArray(): T[] {
        return this.values;
    }

    public static from<U>(values: U[]): TypedList<U> {
        // Perhaps we perform some logic here.
        // ...
    
        return new TypedList<U>(values);
    }

    public static create<U>(values?: U[]): TypedList<U> {
        return new TypedList<U>(values ?? []);
    }

    // Other collection functions.
    // ..
}

Now try making the same mistakes as before. The compiler steps in immediately:

// Here’s the magic. `TypedList` will operate on objects
// of type `User` due to the `<User>` syntax.
const userList = TypedList.create<User>([new User('Jamie')]);

// The compiler expects this.
userList.add(new User('Tom'));
userList.add(new User('Alice'));

// Argument of type '0' is not assignable to parameter 
// of type 'User'. ts(2345)
userList.add(0);

// Property 'getNames' does not exist on type 'User'. 
// Did you mean 'getName'? ts(2551)
// Note: TypeScript infers the type of `users` to be
// `TypedList<User>`
const users = userList.where(user => user.getNames() === 'Jamie');

// Property 'ssn' does not exist on type 'User'. ts(2339)
users.toArray()[0].ssn = '000000000';

// TypeScript infers `usersWithId` to be of type
// `TypedList<`{ id: string, name: string }>
const usersWithId = userList.select(user => ({
    id: newUuid(),
    name: user.getName()
}));

// Property 'ID' does not exist on type '{ id: string; name: string; }'. 
// Did you mean 'id'? ts(2551)
console.log(usersWithId.toArray()[0].ID)

The type arguments you pass can be simple or complex. Numbers, strings, interfaces, classes, even other arrays all work. The moment you provide a type, TypeScript can infer the types of everything downstream, all the way down to individual properties on objects inside the collection:

const numberList = TypedList.create<number>();
numberList.add(4);

const stringList = TypedList.create<string>();
stringList.add('Hello, World');

// Example of a complex type
interface IAircraft {
    apuStatus: ApuStatus;
    inboardOneRPM: number;
    altimeter: number;
    tcasAlert: boolean;

    pushBackAndStart(): Promise<void>;
    ilsCaptureGlidescope(): boolean;
    getFuelStats(): IFuelStats;
    getTCASHistory(): ITCASHistory;
}

const aircraftList = TypedList.create<IAircraft>();
aircraftList.add(/* ... */);

// Aggregate and generate report:
const stats = aircraftList.select(a => ({
    ...a.getFuelStats(),
    ...a.getTCASHistory()
}));

The generic list construction also uses a pattern worth noting: the constructor is private, and static factory methods like create and from handle object construction. create uses the nullish coalescing operator (??) to fall back to an empty array when no initial values are passed, and from accepts an existing array, performs any necessary logic, and then constructs the list. Keeping the constructor clean and delegating setup to factories is good practice for more complex types.

The list also provides select and where methods, which wrap JavaScript’s map and filter respectively. A selector function projects elements into a new form; a predicate — a proposition containing variables — determines which elements pass the filter. The toArray method simply returns the internally held array reference.

This is the core value of generics: you get one type-agnostic implementation that remains fully typed at every use site. You avoid duplicating code, and you avoid sacrificing compile-time checks. The concrete type is deferred until the moment it’s genuinely known, and from that point on, the compiler treats the collection as if it were purpose-built for that specific type.

Why Generics Exist

Generics solve a recurring problem in typed systems: you often need a function or structure that works uniformly across many types, but you still want strict type safety at the call site. The alternative — typing everything as any — forfeits the compiler's checks. Copying implementations per type violates DRY. Generics let you defer the concrete type until the moment the code is actually used.

The canonical illustration is the identity function. In mathematics, f(x) = x returns exactly what it receives. In JavaScript, that's trivial: (input) => input. Moving to TypeScript forces the question: what is the type of input? Using any works but is unsafe; overloading for every type is impractical. Generics provide the right answer:

// ES5 Function
function identity<T>(input: T): T {
    return input;
}

// Arrow Function
const identity = <T>(input: T): T => input;

console.log(identity<number>(5));       // 5
console.log(identity<string>('hello')); // hello

The <T> in the signature declares a generic type parameter. When you call the function, you supply the concrete type just as you supply the value for input:

identity<string>("hello");  // effectively: identity(input: string): string
identity<number>(42);        // effectively: identity(input: number): number

At declaration time, the function is generic because the author doesn't know the caller's type. At call time, the caller knows exactly what they want, and TypeScript substitutes that type for every T in the signature. This is precisely how a third-party library author can ship a function useful to everyone: they defer the type decision until it's actually known.

Thinking of generics as "type parameters" is the right mental model. Just as a function accepts arbitrary values, a generic function accepts an arbitrary type. The convention is to name that type T (for "Type") and move down the alphabet (U, V) when more are needed, though any valid name works.

Generic Syntax Across Structures

TypeScript supports generics on functions, arrow functions, type aliases, interfaces, and classes. The syntax differs slightly, and the scope of the type parameter depends on where it's declared.

Functions and Methods

A generic function can accept multiple type parameters, separated by commas, just like arguments:

function randomValue<T, U, V>(
    one: T, 
    two: U, 
    three: V
): T | U | V  {
    // This is a tuple if you’re not familiar.
    const options: [T, U, V] = [
        one,
        two,
        three
    ];

    const rndNum = getRndNumInInclusiveRange(0, 2);

    return options[rndNum];
}

// Calling the function.
// `value` has type `string | number | IAircraft`
const value = randomValue<
    string,
    number,
    IAircraft
>(
    myString,
    myNumber,
    myAircraft
);

Arrow functions and ES5 functions declare type parameters in the same signature positions; there is no uniqueness constraint on the types you pass. The generic parameters are in scope for the function body and any closures within it. This holds whether the function is standalone or attached to a class, interface, or type alias:

class Utils {
    public randomValue<T, U, V>(
        one: T, 
        two: U, 
        three: V
    ): T | U | V {
        // ...
    }

    // Or, as an arrow function:
    public randomValue = <T, U, V>(
        one: T, 
        two: U, 
        three: V
    ): T | U | V => {
        // ...
    }
}

Note that a type parameter on a member function belongs only to that function. It is not shared with siblings on the same structure. To share a type across all members, you must annotate the structure's name itself.

Type Aliases

Type aliases place the generic syntax on the alias name. A common use case is describing callback shapes. An action that accepts a value and returns nothing:

type Action<T> = (val: T) => void;

A callback that receives an error and a data payload:

type CallbackFunction<T> = (err: Error, data: T) => void;

const usersApi = {
    get(uri: string, cb: CallbackFunction<User>) {
        /// ...
    }
}

Type parameters compose. You can define a generic HTTP client method whose response type flows into the callback type:

type CallbackFunction<TData> = (err: Error, data: TData) => void;

const api = {
    get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) {
        // ...
    }
}

Here api.get<User>() would produce a CallbackFunction<User, Error> — the response type the caller selects becomes the data type passed to the callback. You can add more type parameters for custom error types, and you can assign defaults. Defaults work like optional arguments: if the caller omits the error type, it falls back to the default:

type CallbackFunction<TData, TError = Error> = (err: TError, data: TData) => void;

With defaults in place, callers can specify either one or both types when consuming the alias.

Interfaces

Interfaces can be generic at two levels. A generic member function scopes T to that method only. But placing the type parameter on the interface name makes T available to every member. The Repository Pattern is a canonical example: an interface that persists and retrieves entities of an unknown type:

interface IRepository<T> {
    add(entity: T): Promise<void>;
    findById(id: string): Promise<T>;
    updateById(id: string, updated: T): Promise<void>;
    removeById(id: string): Promise<void>;
}

For an IRepository<User>, every method touching T now operates on User. This uniformity across methods is the key difference from per-function generics — without it, you could add a User but have a findById that returns an Order.

Interfaces can mix a shared type with method-specific types. Mimicking a simple array interface:

interface IArray<T> {
    forEach(func: (elem: T, index: number) => void): this;
    map<U>(func: (elem: T, index: number) => U): IArray<U>;
}

The interface-level T describes the array's element type, available to both methods. The map function additionally declares its own U, representing the new element type produced by the mapping callback. The interface instance itself is returned with the new type to preserve chaining.

Classes

Classes accept type parameters on their name, making the type available to all members, base classes, and implemented interfaces. A simple collection class demonstrates the pattern:

class Collection<T> {
    private elements: T[] = [];

    constructor (elements: T[] = []) {
        this.elements = elements;
    }
    
    add(elem: T): void {
        this.elements.push(elem);
    }
    
    contains(elem: T): boolean {
        return this.elements.includes(elem);
    }
    
    remove(elem: T): void {
        this.elements = this.elements.filter(existing => existing !== elem);
    }
    
    forEach(func: (elem: T, index: number) => void): void {
        return this.elements.forEach(func);
    }
    
    map<U>(func: (elem: T, index: number) => U): Collection<U> {
        return new Collection<U>(this.elements.map(func));
    }
}

const stringCollection = new Collection<string>();
stringCollection.add('Hello, World!');

const numberCollection = new Collection<number>();
numberCollection.add(3.14159);

const aircraftCollection = new Collection<IAircraft>();
aircraftCollection.add(myAircraft);

The collection's private storage is T[], and member methods check, add, and remove only elements of that type. The map method again introduces U because it transforms T into a different type. A function-specific U here is safe — another method could also declare its own U without collision, since they are scoped independently. What you cannot do is redeclare T on another member, as that would clash with the class's T.

Separating the interface from the implementation enables dependency injection. Define a generic collection interface, then build concrete implementations that satisfy it:

interface ICollection<T> {
    add(t: T): void;
    contains(t: T): boolean;
    remove(t: T): void;
    forEach(func: (elem: T, index: number) => void): void;
    map<U>(func: (elem: T, index: number) => U): ICollection<U>;
}

At implementation time you still don't know the element type, so the concrete class accepts its own generic T and passes it straight to the interface:

class InMemoryCollection<T> implements ICollection<T> {
    private elements: T[] = [];

    constructor (elements: T[] = []) {
        this.elements = elements;
    }
    
    add(elem: T): void {
        this.elements.push(elem);
    }
    
    contains(elem: T): boolean {
        return this.elements.includes(elem);
    }
    
    remove(elem: T): void {
        this.elements = this.elements.filter(existing => existing !== elem);
    }
    
    forEach(func: (elem: T, index: number) => void): void {
        return this.elements.forEach(func);
    }
    
    map<U>(func: (elem: T, index: number) => U): ICollection<U> {
        return new InMemoryCollection<U>(this.elements.map(func));
    }
}

Consumers can then depend on the abstraction, accepting any collection implementation that fulfills the interface:

// Using type annotation to be explicit for the purposes of the
// tutorial.
const userCollection: ICollection<User> = new InMemoryCollection<User>();

function manageUsers(userCollection: ICollection<User>) {
    userCollection.add(new User());
}

This decouples business logic from storage details. In tests, an in-memory collection can replace a SQL-backed one, letting you assert on state rather than mocking interactions. Tests become agnostic to implementation and less fragile under refactoring.

Revisiting TypedList<T>

The opening example in this series, which may have seemed complex, should now be clear. The full implementation:

class TypedList<T> {
    private values: T[] = [];

    private constructor (values: T[]) {
        this.values = values;
    }

    public add(value: T): void {
        this.values.push(value);
    }

    public where(predicate: (value: T) => boolean): TypedList<T> {
        return TypedList.from<T>(this.values.filter(predicate));
    }

    public select<U>(selector: (value: T) => U): TypedList<U> {
        return TypedList.from<U>(this.values.map(selector));
    }

    public toArray(): T[] {
        return this.values;
    }

    public static from<U>(values: U[]): TypedList<U> {
        // Perhaps we perform some logic here.
        // ...
    
        return new TypedList<U>(values);
    }

    public static create<U>(values?: U[]): TypedList<U> {
        return new TypedList<U>(values ?? []);
    }

    // Other collection functions.
    // ..
}

The class declares one type parameter T, available to all members. Both static factory methods, from and create, declare their own U. When create is called with a concrete type and seed elements, it passes that type into the class constructor as TypedList<U>. The where method filters without changing types: the predicate receives and returns elements of T, and the filtered result still holds T values, so it can be handed to from to produce a new TypedList<T> for chaining.

The parallel with variable passing is exact. Consider how a value flows through layers of function calls:

class MyClass {
    private constructor (t: number) {}
    
    public static create(u: number) {
        return new MyClass(u);
    }
}

const myClass = MyClass.create(2.17);

Generic types flow the same way — only the payload is a type instead of a value:

class MyClass<T> {
    private constructor () {}
    
    public static create<U>() {
        return new MyClass<U>();
    }
}

const myClass = MyClass.create<number>();

The number passed to create becomes its U, which is then supplied as T to MyClass, effectively constructing a MyClass<number>. The value of generics is that this kind of high-level, type-level composition is possible in a fully checked way.

Letting TypeScript Infer Generic Types

So far, every generic example in this article has explicitly declared the type parameter being used. In practice, however, TypeScript's compiler can often figure out the generic type on its own based on how the code is used. If you pass the result of a function that returns number into the generic identity function, TypeScript won't require you to specify number yourself:

// `value` is inferred as type `number`.
const value = identity(getRandomNumber());

This inference is powerful enough that most type annotations can be stripped away entirely. The TypedList structure built earlier works just as well without the explicit type declarations on every method:

class TypedList<T> {
    private values: T[] = [];

    private constructor (values: T[]) {
        this.values = values;
    }

    public add(value: T) {
        this.values.push(value);
    }

    public where(predicate: (value: T) => boolean) {
        return TypedList.from(this.values.filter(predicate));
    }

    public select<U>(selector: (value: T) => U) {
        return TypedList.from(this.values.map(selector));
    }

    public toArray() {
        return this.values;
    }

    public static from<U>(values: U[]) {
        // Perhaps we perform some logic here.
        // ...
    
        return new TypedList(values);
    }

    public static create<U>(values?: U[]) {
        return new TypedList(values ?? []);
    }

    // Other collection functions.
    // ..
}

TypeScript derives everything it needs from function return values and from the argument types passed into constructors and methods like from. The screenshots below show the language extension in Visual Studio Code inferring each type correctly across multiple call sites:

ts-type-inference
(Large preview)

Constraining Generic Types

Not every generic function should accept every type. When a function needs a specific shape from its inputs, you can restrict what types are allowed. Consider a function that returns the length of some collection: the built-in JavaScript Array has a length property, but so do many custom collection types. A naive generic function that assumes .length exists will fail to compile:

function getLength<T>(collection: T): number {
    // Error. TS does not know that a type T contains a `length` property.
    return collection.length;
}

Generic constraints solve this. Define an interface that describes the minimum set of properties your function requires:

interface IHasLength {
    length: number;
}

Then constrain the generic parameter so it must extend that interface:

function getLength<T extends IHasLength>(collection: T): number {
    // Restricting `collection` to be a type that contains
    // everything within the `IHasLength` interface.
    return collection.length;
}

Applying Generics: Wrapping Error Handling

JavaScript has a first-class error mechanism via try/catch, but it isn't always pleasant to work with directly. A common pattern in data access layers is to hide persistence-specific failures behind a generic interface. Database operations over a network will throw DB-specific and transient errors, and the whole point of a data access layer is keeping those from leaking into business logic.

Relying on raw try/catch blocks inside every data operation means replicating the wrapping logic over and over. A comparison chain using case statements (switching over the literal true) can keep the error checks readable:

async function queryUser(userID: string): Promise<User> {
    try {
        const dbUser = await db.raw(`
            SELECT * FROM users WHERE user_id = ?
        `, [userID]);
        
        return mapper.toDomain(dbUser);
    } catch (e) {
        switch (true) {
            case e instanceof DbErrorOne:
                return Promise.reject(new WrapperErrorOne());
            case e instanceof DbErrorTwo:
                return Promise.reject(new WrapperErrorTwo());
            case e instanceof NetworkError:
                return Promise.reject(new TransientException());
            default:
                return Promise.reject(new UnknownError());
        }
    }
}

A better approach wraps the whole operation in a single generic helper. The function accepts a generic type parameter T, which represents the promised value type of the callback:

async function withErrorHandling<T>(
    dalOperation: () => Promise<T>
): Promise<T> {
    try {
        // This unwraps the promise and returns the type `T`.
        return await dalOperation();
    } catch (e) {
        switch (true) {
            case e instanceof DbErrorOne:
                return Promise.reject(new WrapperErrorOne());
            case e instanceof DbErrorTwo:
                return Promise.reject(new WrapperErrorTwo());
            case e instanceof NetworkError:
                return Promise.reject(new TransientException());
            default:
                return Promise.reject(new UnknownError());
        }
    }
}

Note the extra await is required here. Normally you'd return the promise directly to the caller, but because errors need to be caught inside this wrapper, awaiting is necessary for that logic to run before the promise resolves.

With the helper in place, the original query operation becomes much cleaner:

async function queryUser(userID: string) {
    return withErrorHandling<User>(async () => {
        const dbUser = await db.raw(`
            SELECT * FROM users WHERE user_id = ?
        `, [userID]);
        
        return mapper.toDomain(dbUser);
    });
}

Because TypeScript infers generic types when it has enough information, the explicit type argument is often optional. If the callback returns a User from mapper.toDomain(user), the compiler will infer the generic type without being told:

async function queryUser(userID: string) {
    return withErrorHandling(async () => {
        const dbUser = await db.raw(`
            SELECT * FROM users WHERE user_id = ?
        `, [userID]);
        
        return mapper.toDomain(user);
    });
}

Type-Safe Errors via Monadic Types

Alternatives exist beyond the wrapper pattern. The Either Monad, written Either<T, U> with T typically an error type and U a success type, brings error handling into the type system. A signature like queryUser(userID: string): Promise<Either<NotFound, User>> tells the caller exactly which errors can occur, unlike the bare queryUser(userID: string): Promise<User> form, where thrown errors are invisible to the API contract.

Generics in the Repository Pattern

One of the most practical applications of TypeScript generics is building a data access layer that stays decoupled from business logic. Back-end systems commonly need to talk to a database — PostgreSQL, MongoDB, Neo4j — and if that data access code is scattered across routes and business services, swapping one database for another becomes a painful, bug-prone exercise. The Repository Pattern is a common remedy: it lets calling code treat the data layer as if it were an in-memory collection of domain objects, hiding all persistence details behind a cohesive interface.

Consider two plain domain models, User and Task. They are POTOs (Plain-Old TypeScript Objects) with no database awareness baked in:

interface IHasIdentity {
    id: string;
}

class User implements IHasIdentity {
    public constructor (
        private readonly _id: string,
        private readonly _username: string
    ) {}

    public get id() { return this._id; }
    public get username() { return this._username; }
}

class Task implements IHasIdentity {
    public constructor (
        private readonly _id: string,
        private readonly _title: string
    ) {}

    public get id() { return this._id; }
    public get title() { return this._title; }
}

These models implement an IHasIdentity interface, which becomes important later when we need generic components to access entity IDs in a type-safe way. In a real application these models would also carry logic for maintaining business invariants, but for demonstrating generics they stay intentionally anemic.

Since most repositories share similar persistence operations, we can define a generic interface for them:

interface IRepository<T> {
    add(entity: T): Promise<void>;
    findById(id: string): Promise<T>;
    updateById(id: string, updated: T): Promise<void>;
    deleteById(id: string): Promise<void>;
    existsById(id: string): Promise<boolean>;
}

The calling code never sees the persistence implementation. It simply works with User and Task objects through methods like findById and save. This keeps the business layer agnostic to whether you are using Mongoose, Knex, or something else.

Per-entity repositories can extend that generic interface, adding entity-specific methods as needed. For users, you might create an IUserRepository interface:

interface IUserRepository extends IRepository<User> {
    existsByUsername(username: string): Promise<boolean>;
}

class UserRepository implements IUserRepository {
    // There are 6 methods to implement here all using the 
    // concrete type of `User` - Five from IRepository<User>
    // and the one above.
}

Note that this approach works best when the shared operations are genuinely common across entities. If certain entities are read-only, can’t be deleted, or require different semantics, a one-size-fits-all generic repository becomes a poor fit — some developers prefer to avoid generic repositories altogether for that reason.

Implementing a concrete repository often means handling database-specific errors so they don’t leak into business logic. You can build a base repository class that centralizes those error-handling concerns:

class BaseKnexRepository {
    // A constructor.
    
     /**
     * Wraps a likely to fail database operation within a function that handles errors by catching
     * them and wrapping them in a domain-safe error.
     * 
     * @param dalOp The operation to perform upon the database. 
     */
    public async withErrorHandling<T>(dalOp: () => Promise<T>) {
        try {
            return await dalOp();
        } catch (e) {
            // Use a proper logger:
            console.error(e);
            
            // Handle errors properly here.
        }
    }
}

Then, a specific repository like MongooseUserRepository extends that base and implements the data access methods. Notice the Data Mapper pattern in action: it retrieves a DbUser — a data model tied directly to the database schema — and maps it to a User domain model before returning it. The DbUser never leaves the repository.

interface IUserRepository extends IRepository<User> {
    existsByUsername(username: string): Promise<boolean>;
}

class UserRepository extends BaseKnexRepository implements IUserRepository {
    private readonly dbContext: Knex | Knex.Transaction;
    
    public constructor (private knexInstance: Knex | Knex.Transaction) {
        super();
        this.dbContext = knexInstance;
    }
    
    // Example `findById` implementation:
    public async findById(id: string): Promise<User> {
        return this.withErrorHandling<User>(async () => {
            const dbUser = await this.dbContext<DbUser>()
                .select()
                .where({ user_id: id })
                .first();
                
            // Maps type DbUser to User    
            return mapper.toDomain(dbUser);
        });
    }
    
    // There are 5 methods to implement here all using the 
    // concrete type of `User`.
}

For unit and integration testing, an in-memory repository implementation is useful. Since in-memory storage can use the same generic operations regardless of entity type, you can write one generic base repository:

class InMemoryRepository<T extends IHasIdentity> implements IRepository<T> {
    protected entities: T[] = [];
    
    public findById(id: string): Promise<T> {
        const entityOrNone = this.entities.find(entity => entity.id === id);

        return entityOrNone 
            ? Promise.resolve(entityOrNone)
            : Promise.reject(new NotFound());
    }
    
    // Implement the rest of the IRepository<T> methods here.
}

This base class requires entities to have an id field, which is where the IHasIdentity constraint comes in. The User and Task domain models already implement that interface, so extending the generic in-memory repository is straightforward:

class InMemoryUserRepository extends InMemoryRepository<User> {
    public async existsByUsername(username: string): Promise<boolean> {
        const userOrNone = this.entities.find(entity => entity.username === username);
        return Boolean(userOrNone);

        // or, return !!userOrNone;
    }
    
    // And that’s it here. InMemoryRepository implements the rest.
}

Injecting these repositories into business logic is now trivial:

class UserService {
    public constructor (
        private readonly userRepository: IUserRepository,
        private readonly emailService: IEmailService
    ) {}

    public async createUser(dto: ICreateUserDTO) {
        // Validate the DTO:
        // ...
        
        // Create a User Domain Model from the DTO
        const user = userFactory(dto);

        // Persist the Entity
        await this.userRepository.add(user);
 
        // Send a welcome email
        await this.emailService.sendWelcomeEmail(user);
    }
}

In a production system, you would add transaction support, a unit-of-work pattern, and more careful handling of job queues for tasks like email sending. Those concerns are separate from generics — this example is deliberately simplified to show type-safe data access without coupling layers together.

The outcome is a clear separation between the data access layer and the business logic layer, with full type safety maintained across the boundary. Generics let you write reusable, testable repository components that work for any entity type without sacrificing compile-time checks.

This Repository Pattern is illustrative, not prescriptive. The nuances of proper implementation vary significantly between architectures like N-Tier and DDD, and a robust repository deserves its own detailed treatment.

Generics in React Hooks and Components

Generics are not limited to the back end. React’s state, refs, and function components are generic as well. When you need to hold a collection of Task objects in component state, for example:

import React, { useState } from 'react';

export const MyComponent: React.FC = () => {
    // An empty array of tasks as the initial state:
    const [tasks, setTasks] = useState<Task[]>([]);
    
    // A counter:
    // Notice, type of `number` is inferred automatically.
    const [counter, setCounter] = useState(0);
    
    return (
        <div>
            <h3>Counter Value: {counter}</h3>
            <ul>
                {
                    tasks.map(task => (
                        <li key={task.id}>
                            <TaskItem {...task} />
                        </li>
                    ))
                }
            </ul>
        </div>
    );
};

Function components can use the generic React.FC<T> type to receive props with inferred typing. If you define a separate props interface, the TypeScript compiler resolves the type of props automatically:

import React from 'react';

interface IProps {
    id: string;
    title: string;
    description: string;
}

export const TaskItem: React.FC<IProps> = (props) => {
    return (
        <div>
            <h3>{props.title}</h3>
            <p>{props.description}</p>
        </div>
    );
};

What Generics Ultimately Provide

Generics let you build data structures and components without fixing the concrete type they operate on at compile time. That single idea — delaying the type decision until the point of use — gives you collection utilities, error-handling wrappers, data access layers, and UI components that remain both type-safe and reusable across many different types.