Java Java

Writing Type-Safe Libraries with Java Generics

grivel Июн 24, 2026

Introduction

A data pipeline that handles multiple record types — trades, snapshots, risk reports — faces a tempting shortcut: declare every container as Object, cast when you need the real type, and move on. The code compiles. The tests pass. Then a trade record lands in a snapshot container, the cast fails, and the production system throws a ClassCastException at midnight in code that was "working fine" for weeks.

Java's generics system exists to catch these errors at compile time rather than runtime. A generic class parameterized on T makes the type system enforce that a Pipeline<TradeRecord> never accepts a MarketSnapshot. The compiler rejects invalid operations before any data moves. This is not a style preference — it is the difference between errors that fail immediately and visibly at the source versus errors that corrupt data silently and surface far from their cause.

This tutorial builds a reusable financial analytics pipeline capable of processing TradeRecord, MarketSnapshot, and RiskReport objects without duplicating any logic. Each step introduces one piece of the generics system: generic classes, bounded type parameters, multiple bounds, wildcards, and generic utility methods.


Background

Java generics use type parameters — placeholders written as <T>, <K, V>, or <T extends Comparable<T>> — that are resolved at compile time. The key concepts:

  • Type parameter (<T>): A placeholder for a concrete type supplied when the class or method is used. By convention, T means any type, E means element, K/V mean key/value.
  • Bounded type parameter (<T extends Comparable<T>>): Restricts T to types that implement Comparable. Inside the class, methods declared by Comparable — like compareTo — become available on T.
  • Multiple bounds (<T extends Auditable & Comparable<T>>): T must implement both interfaces. The class comes first if it is a class bound, followed by interfaces separated by &.
  • Upper-bounded wildcard (? extends Record): Accepts any type that is a Record or a subtype. Read-only — you cannot add to a List<? extends Record> because the compiler cannot guarantee which subtype it holds.
  • Lower-bounded wildcard (? super TradeRecord): Accepts any type that is a supertype of TradeRecord. Write-friendly — useful for methods that consume elements rather than produce them.
  • Type erasure: At runtime, all generic type information is erased. List<TradeRecord> and List<MarketSnapshot> are both just List at the bytecode level. This is why you cannot use instanceof to check a parameterized type at runtime.

Practical Scenario

A financial analytics platform receives three classes of records throughout the trading day: TradeRecord objects representing individual executions, MarketSnapshot objects capturing point-in-time price levels, and RiskReport objects summarizing portfolio exposure. All three must flow through the same processing pipeline — ingested, buffered, sorted by timestamp, and dispatched to downstream consumers.

The team's first implementation uses Object everywhere. It compiles and handles all three record types without duplication. But every method that receives a record must cast it to the expected type, and there is nothing stopping a MarketSnapshot from being inserted into a buffer that expects only TradeRecord objects. The cast failures do not happen at insertion — they happen later, at dispatch, where the exception message names the symptom rather than the source.

The platform needs a pipeline library that can be used safely for any record type, where the compiler enforces correct usage and the code reads unambiguously about what each pipeline is for.


The Problem

An Object-based pipeline handles all record types but provides no compile-time safety.

Create a new file:

touch AnalyticsPipeline.java

Compile and run:

javac AnalyticsPipeline.java && java AnalyticsPipeline
import java.util.ArrayList;
import java.util.List;

class TradeRecord {
    String tradeId;
    double notional;
    long   timestamp;

    TradeRecord(String tradeId, double notional, long timestamp) {
        this.tradeId   = tradeId;
        this.notional  = notional;
        this.timestamp = timestamp;
    }

    public String toString() {
        return "TradeRecord{id=" + tradeId + ", notional=" + notional + "}";
    }
}

class MarketSnapshot {
    String symbol;
    double price;
    long   timestamp;

    MarketSnapshot(String symbol, double price, long timestamp) {
        this.symbol    = symbol;
        this.price     = price;
        this.timestamp = timestamp;
    }

    public String toString() {
        return "MarketSnapshot{symbol=" + symbol + ", price=" + price + "}";
    }
}

class ObjectPipeline {
    private List<Object> buffer = new ArrayList<>();

    public void ingest(Object record) {
        buffer.add(record);
    }

    public Object get(int index) {
        return buffer.get(index);
    }

    public int size() {
        return buffer.size();
    }
}

public class AnalyticsPipeline {
    public static void main(String[] args) {
        ObjectPipeline tradePipeline = new ObjectPipeline();
        tradePipeline.ingest(new TradeRecord("TRD-001", 250000.0, 1700000001L));
        tradePipeline.ingest(new MarketSnapshot("AAPL", 185.20, 1700000002L)); // wrong type — no error

        // Cast fails at runtime, not at insertion
        TradeRecord t = (TradeRecord) tradePipeline.get(1);
        System.out.println(t);
    }
}


Exception in thread "main" java.lang.ClassCastException: class MarketSnapshot cannot be cast to class TradeRecord
    at AnalyticsPipeline.main(AnalyticsPipeline.java:56)


The ObjectPipeline accepted the MarketSnapshot without complaint, because its ingest method takes Object. The error surfaces at the cast on line 56 — not at the insertion on the line above it. In a real pipeline with many insertion points and a single dispatch loop, the source of the wrong record may be hundreds of lines and several method calls away from where the ClassCastException fires. The type system provided no help because the types were erased to Object from the start.


Generic Classes

Replacing Object with a type parameter <T> makes the pipeline type-safe. The compiler now tracks what type each Pipeline instance holds and rejects mismatches before any code runs.

Replace the entire content of AnalyticsPipeline.java with the following:

import java.util.ArrayList;
import java.util.List;

class TradeRecord {
    String tradeId;
    double notional;
    long   timestamp;

    TradeRecord(String tradeId, double notional, long timestamp) {
        this.tradeId   = tradeId;
        this.notional  = notional;
        this.timestamp = timestamp;
    }

    public String toString() {
        return "TradeRecord{id=" + tradeId + ", notional=" + notional + "}";
    }
}

class MarketSnapshot {
    String symbol;
    double price;
    long   timestamp;

    MarketSnapshot(String symbol, double price, long timestamp) {
        this.symbol    = symbol;
        this.price     = price;
        this.timestamp = timestamp;
    }

    public String toString() {
        return "MarketSnapshot{symbol=" + symbol + ", price=" + price + "}";
    }
}

class Pipeline<T> {
    private List<T> buffer = new ArrayList<>();

    public void ingest(T record) {
        buffer.add(record);
    }

    public T get(int index) {
        return buffer.get(index);
    }

    public int size() {
        return buffer.size();
    }

    public List<T> drain() {
        List<T> copy = new ArrayList<>(buffer);
        buffer.clear();
        return copy;
    }
}

public class AnalyticsPipeline {
    public static void main(String[] args) {
        Pipeline<TradeRecord> tradePipeline = new Pipeline<>();
        tradePipeline.ingest(new TradeRecord("TRD-001", 250000.0, 1700000001L));
        tradePipeline.ingest(new TradeRecord("TRD-002", 175000.0, 1700000003L));

        // tradePipeline.ingest(new MarketSnapshot("AAPL", 185.20, 1700000002L));
        // ^^^ Compile error: incompatible types — caught before any code runs

        for (TradeRecord t : tradePipeline.drain()) {
            System.out.println(t);
        }

        Pipeline<MarketSnapshot> snapshotPipeline = new Pipeline<>();
        snapshotPipeline.ingest(new MarketSnapshot("AAPL", 185.20, 1700000002L));
        snapshotPipeline.ingest(new MarketSnapshot("MSFT", 378.85, 1700000004L));

        for (MarketSnapshot s : snapshotPipeline.drain()) {
            System.out.println(s);
        }
    }
}


TradeRecord{id=TRD-001, notional=250000.0}
TradeRecord{id=TRD-002, notional=175000.0}
MarketSnapshot{symbol=AAPL, price=185.2}
MarketSnapshot{symbol=MSFT, price=378.85}


Pipeline<TradeRecord> and Pipeline<MarketSnapshot> are distinct types from the compiler's perspective. Attempting to call tradePipeline.ingest(new MarketSnapshot(...)) produces a compile-time error, not a runtime exception. The drain() method returns List<TradeRecord> or List<MarketSnapshot> depending on the instance — no cast required.

The class is written once and works for every record type. The type parameter T propagates through every method signature, so the compiler can verify correct usage everywhere the pipeline is used. Runtime ClassCastException errors from wrong-type insertions become impossible.


Bounded Type Parameters

The Pipeline class sorts records by timestamp before dispatching them. For sorting to work, each record type must be comparable. A bounded type parameter <T extends Comparable<T>> restricts T to types that implement Comparable, making compareTo available inside the class.

Replace the TradeRecord, MarketSnapshot, and Pipeline classes with the following:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

class TradeRecord implements Comparable<TradeRecord> {
    String tradeId;
    double notional;
    long   timestamp;

    TradeRecord(String tradeId, double notional, long timestamp) {
        this.tradeId   = tradeId;
        this.notional  = notional;
        this.timestamp = timestamp;
    }

    public int compareTo(TradeRecord other) {
        return Long.compare(this.timestamp, other.timestamp);
    }

    public String toString() {
        return "TradeRecord{id=" + tradeId + ", ts=" + timestamp + "}";
    }
}

class MarketSnapshot implements Comparable<MarketSnapshot> {
    String symbol;
    double price;
    long   timestamp;

    MarketSnapshot(String symbol, double price, long timestamp) {
        this.symbol    = symbol;
        this.price     = price;
        this.timestamp = timestamp;
    }

    public int compareTo(MarketSnapshot other) {
        return Long.compare(this.timestamp, other.timestamp);
    }

    public String toString() {
        return "MarketSnapshot{symbol=" + symbol + ", ts=" + timestamp + "}";
    }
}

class Pipeline<T extends Comparable<T>> {
    private List<T> buffer = new ArrayList<>();

    public void ingest(T record) {
        buffer.add(record);
    }

    public List<T> drainSorted() {
        List<T> copy = new ArrayList<>(buffer);
        Collections.sort(copy);  // works because T extends Comparable<T>
        buffer.clear();
        return copy;
    }
}

Update main to ingest records out of order and verify sorted output:

public class AnalyticsPipeline {
    public static void main(String[] args) {
        Pipeline<TradeRecord> tradePipeline = new Pipeline<>();
        tradePipeline.ingest(new TradeRecord("TRD-003", 90000.0,  1700000009L));
        tradePipeline.ingest(new TradeRecord("TRD-001", 250000.0, 1700000001L));
        tradePipeline.ingest(new TradeRecord("TRD-002", 175000.0, 1700000005L));

        System.out.println("Trades (sorted by timestamp):");
        for (TradeRecord t : tradePipeline.drainSorted()) {
            System.out.println("  " + t);
        }

        Pipeline<MarketSnapshot> snapshotPipeline = new Pipeline<>();
        snapshotPipeline.ingest(new MarketSnapshot("MSFT", 378.85, 1700000008L));
        snapshotPipeline.ingest(new MarketSnapshot("AAPL", 185.20, 1700000002L));

        System.out.println("Snapshots (sorted by timestamp):");
        for (MarketSnapshot s : snapshotPipeline.drainSorted()) {
            System.out.println("  " + s);
        }
    }
}


Trades (sorted by timestamp):
  TradeRecord{id=TRD-001, ts=1700000001}
  TradeRecord{id=TRD-002, ts=1700000005}
  TradeRecord{id=TRD-003, ts=1700000009}
Snapshots (sorted by timestamp):
  MarketSnapshot{symbol=AAPL, ts=1700000002}
  MarketSnapshot{symbol=MSFT, ts=1700000008}


Collections.sort(copy) compiles because the bound T extends Comparable<T> guarantees that every element has compareTo. Without the bound, sort would not compile — the compiler has no evidence that T supports comparison.

The bound serves as documentation and enforcement simultaneously. A developer who tries to use Pipeline with a class that does not implement Comparable gets a compile-time error, not a runtime failure from a missing method. The class expresses its contract in its signature rather than in a comment that can go stale.


Multiple Bounds

The platform also requires that every record entering the audited pipeline implements an Auditable interface in addition to Comparable. Multiple bounds allow T to be constrained by more than one type, giving the pipeline access to methods from all of them.

Add the Auditable interface and a RiskReport class, then update Pipeline to use multiple bounds:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

interface Auditable {
    String auditTag();
}

class TradeRecord implements Comparable<TradeRecord>, Auditable {
    String tradeId;
    double notional;
    long   timestamp;

    TradeRecord(String tradeId, double notional, long timestamp) {
        this.tradeId   = tradeId;
        this.notional  = notional;
        this.timestamp = timestamp;
    }

    public int compareTo(TradeRecord other) {
        return Long.compare(this.timestamp, other.timestamp);
    }

    public String auditTag() {
        return "TRADE:" + tradeId;
    }

    public String toString() {
        return "TradeRecord{id=" + tradeId + ", notional=" + notional + "}";
    }
}

class RiskReport implements Comparable<RiskReport>, Auditable {
    String portfolioId;
    double varEstimate;
    long   timestamp;

    RiskReport(String portfolioId, double varEstimate, long timestamp) {
        this.portfolioId = portfolioId;
        this.varEstimate = varEstimate;
        this.timestamp   = timestamp;
    }

    public int compareTo(RiskReport other) {
        return Long.compare(this.timestamp, other.timestamp);
    }

    public String auditTag() {
        return "RISK:" + portfolioId;
    }

    public String toString() {
        return "RiskReport{portfolio=" + portfolioId + ", VaR=" + varEstimate + "}";
    }
}

class AuditedPipeline<T extends Comparable<T> & Auditable> {
    private List<T>      buffer    = new ArrayList<>();
    private List<String> auditLog  = new ArrayList<>();

    public void ingest(T record) {
        buffer.add(record);
        auditLog.add("INGESTED " + record.auditTag());
    }

    public List<T> drainSorted() {
        List<T> copy = new ArrayList<>(buffer);
        Collections.sort(copy);
        buffer.clear();
        return copy;
    }

    public List<String> getAuditLog() {
        return auditLog;
    }
}

public class AnalyticsPipeline {
    public static void main(String[] args) {
        AuditedPipeline<TradeRecord> tradePipeline = new AuditedPipeline<>();
        tradePipeline.ingest(new TradeRecord("TRD-002", 175000.0, 1700000005L));
        tradePipeline.ingest(new TradeRecord("TRD-001", 250000.0, 1700000001L));

        AuditedPipeline<RiskReport> riskPipeline = new AuditedPipeline<>();
        riskPipeline.ingest(new RiskReport("PORT-A", 42000.0, 1700000003L));

        System.out.println("Trades:");
        tradePipeline.drainSorted().forEach(t -> System.out.println("  " + t));

        System.out.println("Audit log:");
        tradePipeline.getAuditLog().forEach(e -> System.out.println("  " + e));
        riskPipeline.getAuditLog().forEach(e -> System.out.println("  " + e));
    }
}


Trades:
  TradeRecord{id=TRD-001, notional=250000.0}
  TradeRecord{id=TRD-002, notional=175000.0}
Audit log:
  INGESTED TRADE:TRD-002
  INGESTED TRADE:TRD-001
  INGESTED RISK:PORT-A


Inside AuditedPipeline, record.auditTag() compiles because T extends Auditable guarantees the method exists. Collections.sort(copy) compiles because T extends Comparable<T> is also present. Both constraints are active simultaneously.

Multiple bounds express complex contracts precisely. The syntax <T extends Comparable<T> & Auditable> tells every reader — and the compiler — exactly what T must support. A class that implements Comparable but not Auditable is rejected at the call site with a clear error message, not at some later point where a missing method would throw NoSuchMethodError.


Wildcards: Reading Across Pipeline Types

A reporting utility needs to print the contents of any AuditedPipeline, regardless of whether it holds TradeRecord, RiskReport, or some future record type. An upper-bounded wildcard (? extends Auditable) accepts a parameterized type with any subtype of Auditable, without knowing the exact type at compile time.

Add the following generic utility method to the AnalyticsPipeline class:

    static void printAuditLog(AuditedPipeline<? extends Auditable> pipeline) {
        for (String entry : pipeline.getAuditLog()) {
            System.out.println("  [AUDIT] " + entry);
        }
    }

Update main to call it:

public class AnalyticsPipeline {
    public static void main(String[] args) {
        AuditedPipeline<TradeRecord> tradePipeline = new AuditedPipeline<>();
        tradePipeline.ingest(new TradeRecord("TRD-001", 250000.0, 1700000001L));
        tradePipeline.ingest(new TradeRecord("TRD-002", 175000.0, 1700000005L));

        AuditedPipeline<RiskReport> riskPipeline = new AuditedPipeline<>();
        riskPipeline.ingest(new RiskReport("PORT-A", 42000.0, 1700000003L));
        riskPipeline.ingest(new RiskReport("PORT-B", 18500.0, 1700000007L));

        System.out.println("Trade pipeline audit:");
        printAuditLog(tradePipeline);

        System.out.println("Risk pipeline audit:");
        printAuditLog(riskPipeline);
    }

    static void printAuditLog(AuditedPipeline<? extends Auditable> pipeline) {
        for (String entry : pipeline.getAuditLog()) {
            System.out.println("  [AUDIT] " + entry);
        }
    }
}


Trade pipeline audit:
  [AUDIT] INGESTED TRADE:TRD-001
  [AUDIT] INGESTED TRADE:TRD-002
Risk pipeline audit:
  [AUDIT] INGESTED RISK:PORT-A
  [AUDIT] INGESTED RISK:PORT-B


printAuditLog accepts AuditedPipeline<TradeRecord> and AuditedPipeline<RiskReport> with a single method signature. Without the wildcard, you would need separate overloads for each type, or you would have to pass the pipeline as a raw AuditedPipeline and lose all type information.

Upper-bounded wildcards (? extends T) are the correct tool when a method only reads from a collection or generic container — it needs to know that the elements satisfy some interface, but it does not care which specific subtype they are. The rule is: ? extends T for reading (producer), ? super T for writing (consumer). printAuditLog reads the audit log and calls Auditable methods — a textbook upper-bounded wildcard use case.

Note: You cannot call pipeline.ingest(...) inside printAuditLog even if you wanted to — the compiler forbids writes into ? extends wildcarded types because it cannot verify which specific subtype the wildcard resolved to.


Generic Utility Methods

A generic static method parameterizes the method itself rather than the class, allowing type-safe operations on any AuditedPipeline type without wildcards. The type parameter is declared immediately before the return type: <T extends Comparable<T> & Auditable>.

Add the following utility method and update main:

    static <T extends Comparable<T> & Auditable> T findEarliest(AuditedPipeline<T> pipeline) {
        List<T> sorted = pipeline.drainSorted();
        if (sorted.isEmpty()) {
            throw new IllegalStateException("Pipeline is empty");
        }
        return sorted.get(0);
    }
public class AnalyticsPipeline {
    public static void main(String[] args) {
        AuditedPipeline<TradeRecord> tradePipeline = new AuditedPipeline<>();
        tradePipeline.ingest(new TradeRecord("TRD-003", 90000.0,  1700000009L));
        tradePipeline.ingest(new TradeRecord("TRD-001", 250000.0, 1700000001L));
        tradePipeline.ingest(new TradeRecord("TRD-002", 175000.0, 1700000005L));

        AuditedPipeline<RiskReport> riskPipeline = new AuditedPipeline<>();
        riskPipeline.ingest(new RiskReport("PORT-B", 18500.0, 1700000007L));
        riskPipeline.ingest(new RiskReport("PORT-A", 42000.0, 1700000003L));

        TradeRecord earliestTrade = findEarliest(tradePipeline);
        System.out.println("Earliest trade:  " + earliestTrade);

        RiskReport earliestRisk = findEarliest(riskPipeline);
        System.out.println("Earliest report: " + earliestRisk);
    }

    static void printAuditLog(AuditedPipeline<? extends Auditable> pipeline) {
        for (String entry : pipeline.getAuditLog()) {
            System.out.println("  [AUDIT] " + entry);
        }
    }

    static <T extends Comparable<T> & Auditable> T findEarliest(AuditedPipeline<T> pipeline) {
        List<T> sorted = pipeline.drainSorted();
        if (sorted.isEmpty()) {
            throw new IllegalStateException("Pipeline is empty");
        }
        return sorted.get(0);
    }
}


Earliest trade:  TradeRecord{id=TRD-001, notional=250000.0}
Earliest report: RiskReport{portfolio=PORT-A, VaR=42000.0}


findEarliest returns T — a TradeRecord when called with a TradeRecord pipeline, a RiskReport when called with a RiskReport pipeline. No cast is needed at the call site. The type is inferred from the argument.

Generic methods let the type parameter flow from argument to return type, which is impossible with wildcards. ? extends works for reading elements with a shared interface; generic methods work when you need the return type to match the specific parameterized type of the argument. The compiler infers T from context, so call sites are clean with no explicit type witness required.


Summary

Java generics eliminate the gap between "this compiles" and "this is correct" for data structures and utility methods that operate across multiple types. This tutorial built a financial analytics pipeline that handles TradeRecord, MarketSnapshot, and RiskReport objects without any type duplication:

  • A generic class <T> replaces Object as the container element type, making the compiler track which type each instance holds and preventing wrong-type insertions at compile time rather than at runtime
  • A bounded type parameter <T extends Comparable<T>> restricts T to types that support comparison, making methods like Collections.sort available inside the class without unsafe casts
  • Multiple bounds <T extends Comparable<T> & Auditable> enforce multiple contracts simultaneously — the class comes first in the bound list if present, followed by interfaces separated by &
  • Upper-bounded wildcards ? extends T allow a method to accept any parameterized variant of a generic type for reading — use ? extends T when the method produces values of T, ? super T when it consumes them
  • Wildcarded parameters cannot accept writes — pipeline.ingest(x) is a compile error inside a method that received Pipeline<? extends T> because the compiler cannot verify which concrete subtype the wildcard resolved to
  • Generic static methods <T> ReturnType methodName(...) declare the type parameter before the return type, allowing the return type to match the parameterized type of the input argument — necessary when wildcards would lose the concrete type

Чтобы получить доступ к облачной лаборатории, необходимо войти в систему.

Войти