Java Generic Interfaces
Java Generic Interfaces
A generic interface declares type parameters the same way a generic class does — angle brackets after the interface name, with the parameters usable anywhere a type name appears in the interface body. The difference between a generic interface and a generic class is where the type decision is enforced: a class holds state, so the type argument matters from construction time through the object's lifetime; an interface declares a contract, so the type argument matters for every class that implements it and every call site that invokes it. Comparable<T>, Iterable<E>, Supplier<T>, Predicate<T>, and Function<T,R> in the JDK are all generic interfaces — each one is written once and serves every type that implements or uses it.
What Is a Generic Interface?
A generic interface is declared with one or more type parameters in angle brackets after the interface name. Those parameters appear in the abstract method signatures, default method bodies, and optionally in static method signatures inside the interface.
SYNTAX:
public interface InterfaceName<T> {
void doSomething(T input); // T used as parameter type
T produce(); // T used as return type
boolean test(T value); // T used in conditional method
}
public interface InterfaceName<T, R> {
R transform(T input); // T in, R out - independent types
}
// With bounded type parameter:
public interface Sortable<T extends Comparable<T>> {
int compareTo(T other);
}
IMPLEMENTING A GENERIC INTERFACE - three patterns:
PATTERN 1: Pass the type parameter through (implementing class stays generic)
class TypedProcessor<T> implements Processor<T> { ... }
// TypedProcessor<String> is a Processor<String>
// TypedProcessor<Order> is a Processor<Order>
PATTERN 2: Fix the type argument (implementing class is concrete)
class StringProcessor implements Processor<String> { ... }
// Always a Processor<String> - no type parameter on the class
PATTERN 3: Lambda expression (for single-abstract-method interfaces)
Processor<String> p = input -> input.toUpperCase();
// Only works when Processor has exactly one abstract method
Basic Overview - The Four Things to Know About Generic Interfaces
1. WHERE THE TYPE PARAMETER LIVES IN AN INTERFACE
Fresher view : the <T> after the interface name is the same
syntax as in a generic class. Method signatures
inside the interface use T wherever a type would
normally go. Each implementing class decides
what T actually is.
Deeper view : the interface itself is never instantiated -
the type parameter is resolved by the implementing
class or by the caller creating an anonymous class
or lambda. The compiler checks every implementation
against the declared T and rejects mismatches.
2. THE THREE IMPLEMENTATION PATTERNS
Fresher view : you can implement a generic interface by
staying generic yourself (pass T through),
by locking T to a specific type (concrete class),
or by using a lambda if the interface has one
abstract method.
Deeper view : when a class passes T through - "implements
Processor<T>" - the class and interface share the
same parameter name by convention but it is a
single binding: Box<String> implementing Container<T>
means T=String for that Box. When a class fixes T -
"implements Processor<String>" - the compiler
verifies that every method the class provides
matches the String-specialised contract.
3. DEFAULT METHODS IN GENERIC INTERFACES
Fresher view : a generic interface can have default methods
(methods with a body) that use the type parameter.
Implementing classes inherit these and can override
them or use them as-is.
Deeper view : default methods in generic interfaces are erased
the same way as abstract methods - T becomes Object
in bytecode. The default method implementation is
stored in the interface's class file and called via
invokeinterface at runtime. Overriding a default
method in a generic implementing class follows the
same override rules as any method override.
4. FUNCTIONAL INTERFACES AND LAMBDAS
Fresher view : if a generic interface has exactly ONE abstract
method, any lambda with the matching signature is
automatically an implementation of it.
Predicate<String> p = s -> s.isEmpty(); works
because Predicate<T> has one abstract method test(T).
Deeper view : the compiler matches the lambda's parameter types
and return type against the single abstract method
after substituting the type argument. The lambda
implements the interface's SAM (Single Abstract
Method). Adding @FunctionalInterface protects the
interface from accidentally gaining a second
abstract method, which would break every lambda
that implements it.
Declaring and Using a Generic Interface
Single Type Parameter
1// File: Transformer.java
2
3// A generic interface that transforms a value of type T into another T.
4// The transformation contract is the same for every T - only the type changes.
5public interface Transformer<T> {
6
7 // Abstract method - every implementation must provide this
8 T transform(T input);
9
10 // Default method - uses T in its signature and delegates to transform()
11 // Implementing classes inherit this for free or can override it
12 default T transformAndLog(T input) {
13 T result = transform(input);
14 System.out.println(" Transformed: " + input + " -> " + result);
15 return result;
16 }
17
18 // Static factory - creates a no-op transformer that returns input unchanged
19 // Static methods in interfaces CAN use T only via their OWN type parameter
20 static <T> Transformer<T> identity() {
21 return input -> input; // lambda - works because Transformer has one abstract method
22 }
23}1// File: TransformerDemo.java
2
3public class TransformerDemo {
4
5 // PATTERN 1: Generic implementing class - passes T through
6 // StringTransformer<T> can wrap any Transformer<T>
7 static class UpperCaseTransformer implements Transformer<String> {
8 @Override
9 public String transform(String input) {
10 return input == null ? "" : input.toUpperCase();
11 }
12 }
13
14 // PATTERN 2: Concrete implementing class - T fixed to Integer
15 static class DoubleValueTransformer implements Transformer<Integer> {
16 @Override
17 public Integer transform(Integer input) {
18 return input == null ? 0 : input * 2;
19 }
20 }
21
22 public static void main(String[] args) {
23
24 System.out.println("=== UpperCaseTransformer (T = String) ===");
25 Transformer<String> upper = new UpperCaseTransformer();
26 upper.transformAndLog("hello, meesho!");
27 upper.transformAndLog("product catalog");
28
29 System.out.println();
30
31 System.out.println("=== DoubleValueTransformer (T = Integer) ===");
32 Transformer<Integer> doubler = new DoubleValueTransformer();
33 doubler.transformAndLog(500);
34 doubler.transformAndLog(1299);
35
36 System.out.println();
37
38 System.out.println("=== Lambda implementation (T = String) ===");
39 // Lambda works because Transformer has exactly one abstract method
40 Transformer<String> trimmer = input -> input == null ? "" : input.trim();
41 trimmer.transformAndLog(" Laptop Stand ");
42 trimmer.transformAndLog(" USB Hub ");
43
44 System.out.println();
45
46 System.out.println("=== Identity transformer ===");
47 Transformer<String> noOp = Transformer.identity();
48 noOp.transformAndLog("unchanged value");
49 }
50}Output:
=== UpperCaseTransformer (T = String) ===
Transformed: hello, meesho! -> HELLO, MEESHO!
Transformed: product catalog -> PRODUCT CATALOG
=== DoubleValueTransformer (T = Integer) ===
Transformed: 500 -> 1000
Transformed: 1299 -> 2598
=== Lambda implementation (T = String) ===
Transformed: Laptop Stand -> Laptop Stand
Transformed: USB Hub -> USB Hub
=== Identity transformer ===
Transformed: unchanged value -> unchanged value
Two Type Parameters
When two independent type roles exist in a contract — an input type and an output type — a two-parameter generic interface expresses both cleanly. This is the shape of Function<T,R> in java.util.function, and it is the pattern behind converters, mappers, and serializers.
1// File: Converter.java
2
3// Converter<S, T> converts a value of type S (Source) to type T (Target).
4// S and T are completely independent - Converter<String, Integer> and
5// Converter<Order, OrderDto> are unrelated parameterized types.
6public interface Converter<S, T> {
7
8 T convert(S source);
9
10 // Default method - converts and validates the result
11 default T convertOrDefault(S source, T defaultValue) {
12 if (source == null) return defaultValue;
13 T result = convert(source);
14 return result != null ? result : defaultValue;
15 }
16}1// File: ConverterDemo.java
2
3public class ConverterDemo {
4
5 record ProductDto(String id, String name, String formattedPrice) {}
6
7 record Product(String productId, String name, double price) {
8 String getProductId() { return productId; }
9 String getName() { return name; }
10 double getPrice() { return price; }
11 }
12
13 public static void main(String[] args) {
14
15 System.out.println("=== Converter<String, Integer>: parse price strings ===");
16 // Lambda implementing Converter<String, Integer>
17 Converter<String, Integer> priceParser = source -> {
18 try {
19 return Integer.parseInt(source.replaceAll("[^0-9]", ""));
20 } catch (NumberFormatException e) {
21 return null;
22 }
23 };
24
25 System.out.println("Rs.1499 -> " + priceParser.convert("Rs.1499"));
26 System.out.println("Rs.2,999 -> " + priceParser.convert("Rs.2,999"));
27 System.out.println("free -> " + priceParser.convertOrDefault("free", 0));
28
29 System.out.println();
30
31 System.out.println("=== Converter<Product, ProductDto>: domain to DTO ===");
32 // Lambda implementing Converter<Product, ProductDto>
33 Converter<Product, ProductDto> toDto = product ->
34 new ProductDto(
35 product.getProductId(),
36 product.getName(),
37 "Rs." + String.format("%.0f", product.getPrice())
38 );
39
40 Product p1 = new Product("ZPT-101", "Basmati Rice 5kg", 499.0);
41 Product p2 = new Product("ZPT-102", "Cold Press Oil 1L", 349.0);
42
43 ProductDto dto1 = toDto.convert(p1); // ProductDto - no cast
44 ProductDto dto2 = toDto.convert(p2);
45 System.out.println(dto1);
46 System.out.println(dto2);
47
48 System.out.println();
49
50 System.out.println("=== convertOrDefault with null source ===");
51 ProductDto fallback = new ProductDto("UNKNOWN", "Product unavailable", "N/A");
52 ProductDto result = toDto.convertOrDefault(null, fallback);
53 System.out.println(result);
54 }
55}Output:
=== Converter<String, Integer>: parse price strings ===
Rs.1499 -> 1499
Rs.2,999 -> 2999
free -> 0
=== Converter<Product, ProductDto>: domain to DTO ===
ProductDto[id=ZPT-101, name=Basmati Rice 5kg, formattedPrice=Rs.499]
ProductDto[id=ZPT-102, name=Cold Press Oil 1L, formattedPrice=Rs.349]
=== convertOrDefault with null source ===
ProductDto[id=UNKNOWN, name=Product unavailable, formattedPrice=N/A]
JDK Generic Interfaces You Use Every Day
Understanding that Comparable<T>, Iterable<E>, Runnable, Comparable<T>, and java.util.function.* are all generic interfaces - and understanding what their type parameters mean - makes every interaction with them more intentional.
1// File: JdkGenericInterfacesDemo.java
2
3import java.util.*;
4import java.util.function.*;
5
6public class JdkGenericInterfacesDemo {
7
8 // Implementing Comparable<T> - the class fixes T to itself (the canonical pattern)
9 // This makes instances of OrderSummary sortable by amount
10 record OrderSummary(String orderId, double amount) implements Comparable<OrderSummary> {
11
12 // T is fixed to OrderSummary - compareTo accepts exactly OrderSummary
13 @Override
14 public int compareTo(OrderSummary other) {
15 return Double.compare(this.amount, other.amount);
16 }
17 }
18
19 // Implementing Iterable<E> - the class fixes E to CartItem
20 // This makes CartCollection usable in a for-each loop
21 record CartItem(String name, double price) {}
22
23 static class CartCollection implements Iterable<CartItem> {
24 private final List<CartItem> items = new ArrayList<>();
25
26 void add(CartItem item) { items.add(item); }
27
28 // E is fixed to CartItem - iterator() returns Iterator<CartItem>
29 @Override
30 public Iterator<CartItem> iterator() {
31 return items.iterator();
32 }
33 }
34
35 public static void main(String[] args) {
36
37 System.out.println("=== Comparable<OrderSummary> - sorting by amount ===");
38 List<OrderSummary> orders = new ArrayList<>(List.of(
39 new OrderSummary("ORD-3", 7500.0),
40 new OrderSummary("ORD-1", 1200.0),
41 new OrderSummary("ORD-2", 4300.0)
42 ));
43 Collections.sort(orders); // uses compareTo(OrderSummary)
44 orders.forEach(o -> System.out.println(" " + o.orderId() + ": Rs." + o.amount()));
45
46 System.out.println();
47
48 System.out.println("=== Iterable<CartItem> - for-each loop ===");
49 CartCollection cart = new CartCollection();
50 cart.add(new CartItem("Wireless Mouse", 799.0));
51 cart.add(new CartItem("Laptop Stand", 1299.0));
52 cart.add(new CartItem("USB-C Cable", 299.0));
53
54 for (CartItem item : cart) { // compiler calls cart.iterator() behind the scenes
55 System.out.println(" " + item.name() + " - Rs." + item.price());
56 }
57
58 System.out.println();
59
60 System.out.println("=== java.util.function generic interfaces ===");
61 // Predicate<T> - one abstract method test(T t): boolean
62 Predicate<OrderSummary> highValue = order -> order.amount() > 5000.0;
63 System.out.println("ORD-3 is high value? " + highValue.test(new OrderSummary("ORD-3", 7500.0)));
64
65 // Function<T, R> - one abstract method R apply(T t)
66 Function<OrderSummary, String> formatter =
67 order -> order.orderId() + " = Rs." + order.amount();
68 System.out.println(formatter.apply(new OrderSummary("ORD-2", 4300.0)));
69
70 // Supplier<T> - one abstract method T get()
71 Supplier<OrderSummary> defaultOrder = () -> new OrderSummary("ORD-DEFAULT", 0.0);
72 System.out.println("Default: " + defaultOrder.get());
73
74 // Consumer<T> - one abstract method void accept(T t)
75 Consumer<OrderSummary> logger = order ->
76 System.out.println(" [LOG] Processing: " + order.orderId());
77 orders.forEach(logger);
78 }
79}Output:
=== Comparable<OrderSummary> - sorting by amount ===
ORD-1: Rs.1200.0
ORD-2: Rs.4300.0
ORD-3: Rs.7500.0
=== Iterable<CartItem> - for-each loop ===
Wireless Mouse - Rs.799.0
Laptop Stand - Rs.1299.0
USB-C Cable - Rs.299.0
=== java.util.function generic interfaces ===
ORD-3 is high value? true
ORD-2 = Rs.4300.0
Default: OrderSummary[orderId=ORD-DEFAULT, amount=0.0]
[LOG] Processing: ORD-1
[LOG] Processing: ORD-2
[LOG] Processing: ORD-3
Generic Interface vs Generic Class - The Key Difference
GENERIC INTERFACE:
- Declares a CONTRACT that implementations must satisfy
- Cannot hold state (no instance fields)
- Multiple unrelated classes can implement the same generic interface
with different type arguments simultaneously
- The type argument is resolved by the implementing class
or by the lambda/anonymous class at the usage site
- Default methods can provide shared behavior using T
- Static methods can provide factory behavior using their OWN type params
Comparable<T> is a contract: "objects of type T can be compared
against each other." String, Integer, LocalDate, and OrderSummary
all implement it independently, with T fixed to themselves.
GENERIC CLASS:
- Declares an IMPLEMENTATION with state
- Holds T-typed fields, sets them in the constructor
- One class, one parameterization per instance
- The type argument is resolved when calling new ClassName<>()
ArrayList<E> is an implementation: it HOLDS elements of type E
in an internal array, manages their storage and retrieval.
THE PRACTICAL RELATIONSHIP:
Most real-world designs pair a generic interface with one or more
generic (or concrete) implementing classes:
Converter<S,T> <- generic interface (contract)
PriceStringConverter <- concrete class implementing Converter<String, Integer>
ProductToDtoConverter <- concrete class implementing Converter<Product, ProductDto>
s -> s.toUpperCase() <- lambda implementing Converter<String, String>
The interface defines the shape. The implementations define the behavior.
Any code that works with Converter<String, Integer> can use any of
these implementations interchangeably.
Real-World Example - Razorpay Payment Gateway Strategy
A payment processing system handles multiple payment methods — UPI, card, and net banking — each with different processing logic but the same structural contract: validate the payment request, process it, and return a standardized result. A generic PaymentGateway<T> interface where T is the payment request type lets each gateway declare exactly what it accepts, while a dispatcher that holds a Map of gateways invokes the right one at runtime without knowing the specific request type.
1// File: PaymentResult.java
2
3public record PaymentResult(
4 boolean success,
5 String transactionId,
6 double amountProcessed,
7 String message
8) {
9 static PaymentResult success(String txnId, double amount) {
10 return new PaymentResult(true, txnId, amount, "Payment successful");
11 }
12
13 static PaymentResult failure(String reason) {
14 return new PaymentResult(false, null, 0.0, reason);
15 }
16
17 @Override
18 public String toString() {
19 return success
20 ? "PaymentResult[SUCCESS, txn=" + transactionId + ", amount=Rs." + amountProcessed + "]"
21 : "PaymentResult[FAILURE, reason=" + message + "]";
22 }
23}1// File: PaymentGateway.java
2
3// T is the specific payment request type this gateway handles.
4// UpiGateway implements PaymentGateway<UpiRequest>.
5// CardGateway implements PaymentGateway<CardRequest>.
6// Each gateway is type-safe about what it accepts.
7public interface PaymentGateway<T> {
8
9 // Validate the request before attempting to process
10 boolean validate(T request);
11
12 // Process and return a result
13 PaymentResult process(T request);
14
15 // Default method - validate then process, return failure if invalid
16 default PaymentResult validateAndProcess(T request) {
17 if (!validate(request)) {
18 return PaymentResult.failure("Validation failed for request: " + request);
19 }
20 return process(request);
21 }
22
23 // Return the name of this gateway for logging and routing
24 String gatewayName();
25}1// File: UpiRequest.java
2
3public record UpiRequest(String vpa, double amount, String orderId) {
4 boolean isValidVpa() {
5 return vpa != null && vpa.contains("@");
6 }
7}1// File: CardRequest.java
2
3public record CardRequest(String cardNumber, String cvv, String expiryMonth,
4 String expiryYear, double amount, String orderId) {
5 boolean isValidCard() {
6 return cardNumber != null && cardNumber.length() == 16
7 && cvv != null && cvv.length() == 3;
8 }
9}1// File: UpiGateway.java
2
3// T is fixed to UpiRequest - UpiGateway handles only UPI payments.
4// The compiler ensures validate() and process() accept UpiRequest specifically.
5public class UpiGateway implements PaymentGateway<UpiRequest> {
6
7 @Override
8 public String gatewayName() { return "UPI_GATEWAY"; }
9
10 @Override
11 public boolean validate(UpiRequest request) {
12 return request != null
13 && request.isValidVpa()
14 && request.amount() > 0
15 && request.amount() <= 100000.0;
16 }
17
18 @Override
19 public PaymentResult process(UpiRequest request) {
20 System.out.println(" [UPI] Processing payment to VPA: " + request.vpa());
21 String txnId = "UPI-" + System.currentTimeMillis() % 100000;
22 return PaymentResult.success(txnId, request.amount());
23 }
24}1// File: CardGateway.java
2
3// T is fixed to CardRequest - CardGateway handles only card payments.
4public class CardGateway implements PaymentGateway<CardRequest> {
5
6 @Override
7 public String gatewayName() { return "CARD_GATEWAY"; }
8
9 @Override
10 public boolean validate(CardRequest request) {
11 return request != null
12 && request.isValidCard()
13 && request.amount() > 0;
14 }
15
16 @Override
17 public PaymentResult process(CardRequest request) {
18 System.out.println(" [CARD] Processing card ending: "
19 + request.cardNumber().substring(12));
20 String txnId = "CARD-" + System.currentTimeMillis() % 100000;
21 return PaymentResult.success(txnId, request.amount());
22 }
23}1// File: PaymentGatewayDemo.java
2
3public class PaymentGatewayDemo {
4
5 public static void main(String[] args) {
6
7 UpiGateway upiGateway = new UpiGateway();
8 CardGateway cardGateway = new CardGateway();
9
10 System.out.println("=== UPI payment - valid VPA ===");
11 UpiRequest validUpi = new UpiRequest("rahul@upi", 1499.0, "ORD-5001");
12 PaymentResult upiResult = upiGateway.validateAndProcess(validUpi);
13 System.out.println(upiResult);
14
15 System.out.println();
16
17 System.out.println("=== UPI payment - invalid VPA ===");
18 UpiRequest badVpa = new UpiRequest("not-a-vpa", 500.0, "ORD-5002");
19 PaymentResult failedVpa = upiGateway.validateAndProcess(badVpa);
20 System.out.println(failedVpa);
21
22 System.out.println();
23
24 System.out.println("=== Card payment - valid card ===");
25 CardRequest validCard = new CardRequest(
26 "4111111111111111", "123", "12", "2027", 3499.0, "ORD-5003");
27 PaymentResult cardResult = cardGateway.validateAndProcess(validCard);
28 System.out.println(cardResult);
29
30 System.out.println();
31
32 System.out.println("=== Card payment - invalid CVV length ===");
33 CardRequest badCvv = new CardRequest(
34 "4111111111111111", "12", "12", "2027", 2000.0, "ORD-5004");
35 PaymentResult failedCvv = cardGateway.validateAndProcess(badCvv);
36 System.out.println(failedCvv);
37
38 System.out.println();
39
40 System.out.println("=== Gateway names for audit log ===");
41 System.out.println("Gateway 1: " + upiGateway.gatewayName());
42 System.out.println("Gateway 2: " + cardGateway.gatewayName());
43 }
44}Output:
=== UPI payment - valid VPA ===
[UPI] Processing payment to VPA: rahul@upi
PaymentResult[SUCCESS, txn=UPI-xxxxx, amount=Rs.1499.0]
=== UPI payment - invalid VPA ===
PaymentResult[FAILURE, reason=Validation failed for request: UpiRequest[vpa=not-a-vpa, amount=500.0, orderId=ORD-5002]]
=== Card payment - valid card ===
[CARD] Processing card ending: 1111
PaymentResult[SUCCESS, txn=CARD-xxxxx, amount=Rs.3499.0]
=== Card payment - invalid CVV length ===
PaymentResult[FAILURE, reason=Validation failed for request: CardRequest[cardNumber=4111111111111111, cvv=12, expiryMonth=12, expiryYear=2027, amount=2000.0, orderId=ORD-5004]]
=== Gateway names for audit log ===
Gateway 1: UPI_GATEWAY
Gateway 2: CARD_GATEWAY
UpiGateway.validate() accepts exactly UpiRequest and nothing else — the compiler enforces that. CardGateway.validate() accepts exactly CardRequest. Neither gateway needs to cast its argument or check its type at runtime: the type contract is resolved at compile time when T is fixed to the specific request type. The validateAndProcess() default method in the interface works for both, inherited without being reimplemented. Adding a new payment method means implementing PaymentGateway<NetBankingRequest> — the interface contract guides exactly what to implement.
Extending Generic Interfaces
A generic interface can extend one or more other interfaces, including other generic interfaces. The type parameters can be passed through, fixed, or combined.
1// File: InterfaceExtensionDemo.java
2
3import java.util.List;
4
5public class InterfaceExtensionDemo {
6
7 // Base generic interface
8 interface Repository<T, ID> {
9 void save(T entity);
10 T findById(ID id);
11 List<T> findAll();
12 }
13
14 // Extended interface - adds pagination, keeps the same parameters
15 interface PageableRepository<T, ID> extends Repository<T, ID> {
16 List<T> findPage(int page, int pageSize);
17 int countAll();
18 }
19
20 // Narrower extended interface - fixes ID to Long, T stays free
21 interface LongIdRepository<T> extends Repository<T, Long> {
22 // Methods inherited from Repository<T, Long>
23 // findById() now returns T for a Long ID specifically
24 }
25
26 record Product(Long id, String name) {}
27
28 // Implementing PageableRepository<Product, Long>
29 // T=Product, ID=Long - both parameters supplied
30 static class InMemoryProductRepo implements PageableRepository<Product, Long> {
31 private final java.util.Map<Long, Product> store = new java.util.LinkedHashMap<>();
32
33 @Override public void save(Product p) { store.put(p.id(), p); }
34 @Override public Product findById(Long id) { return store.get(id); }
35 @Override public List<Product> findAll() { return List.copyOf(store.values()); }
36
37 @Override
38 public List<Product> findPage(int page, int pageSize) {
39 return store.values().stream()
40 .skip((long) (page - 1) * pageSize)
41 .limit(pageSize)
42 .toList();
43 }
44
45 @Override public int countAll() { return store.size(); }
46 }
47
48 public static void main(String[] args) {
49 InMemoryProductRepo repo = new InMemoryProductRepo();
50 repo.save(new Product(1L, "Wireless Mouse"));
51 repo.save(new Product(2L, "Laptop Stand"));
52 repo.save(new Product(3L, "USB Hub"));
53 repo.save(new Product(4L, "Webcam HD"));
54 repo.save(new Product(5L, "Keyboard"));
55
56 System.out.println("Total products: " + repo.countAll());
57
58 System.out.println("Page 1 (size 2):");
59 repo.findPage(1, 2).forEach(p -> System.out.println(" " + p));
60
61 System.out.println("Page 2 (size 2):");
62 repo.findPage(2, 2).forEach(p -> System.out.println(" " + p));
63
64 System.out.println("By ID 3: " + repo.findById(3L));
65 }
66}Output:
Total products: 5
Page 1 (size 2):
Product[id=1, name=Wireless Mouse]
Product[id=2, name=Laptop Stand]
Page 2 (size 2):
Product[id=3, name=USB Hub]
Product[id=4, name=Webcam HD]
By ID 3: Product[id=3, name=USB Hub]
Best Practices
Use a generic interface when multiple unrelated types need to satisfy the same contract. Comparable<T> works for String, Integer, LocalDate, and any custom class. One interface, countless implementations — no duplication of the sort contract. If the behavior is so specific to one type that no other type could meaningfully implement it, a concrete method on that class is a better fit than a generic interface.
Prefer two-parameter generic interfaces over two single-parameter ones when the types are related. Converter<S,T> expresses "I convert from S to T" in one interface with the relationship visible in the signature. Two separate interfaces Source<S> and Target<T> would lose the converter's directional relationship. Pair the type parameters when they describe two sides of one operation.
Use @FunctionalInterface on any single-abstract-method generic interface designed for lambda use. It prevents accidental addition of a second abstract method (which would silently break every lambda targeting the interface) and communicates to callers that lambda syntax is the intended usage. Every interface in java.util.function is annotated this way.
In implementing classes, be explicit about which implementation pattern you are using. class UpperCase implements Transformer<String> clearly fixes T to String. class Box<T> implements Container<T> clearly passes T through. Both patterns are valid; the choice should be deliberate and visible in the class declaration, not something a reader has to infer from the method signatures.
Do not design a generic interface whose type parameter serves no purpose in any method signature. A type parameter that appears only in the class declaration and never in any method signature is purely decorative — it adds complexity without adding type safety. The type parameter earns its place only when it appears in at least one abstract or default method.
Common Mistakes
Mistake 1 - Implementing the Same Generic Interface Twice With Different Type Arguments
1import java.util.List;
2
3// WRONG - a class cannot implement the same generic interface twice
4// with different type arguments. COMPILE ERROR.
5// After erasure, both become Converter, and two erasures of the same
6// interface conflict in the class file.
7class DoubleConverter
8 implements Converter<String, Integer>,
9 Converter<String, Double> { // COMPILE ERROR
10 // "a type cannot implement both Converter<String,Integer>
11 // and Converter<String,Double>"
12}
13
14// CORRECT - use two separate methods or two separate classes
15class StringToIntConverter implements Converter<String, Integer> {
16 @Override public Integer convert(String s) { return Integer.parseInt(s); }
17}
18
19class StringToDoubleConverter implements Converter<String, Double> {
20 @Override public Double convert(String s) { return Double.parseDouble(s); }
21}Mistake 2 - Treating the Type Argument as Known Inside the Interface
1// WRONG - an interface body cannot assume T is a specific type.
2// Calling String-specific methods on T is a COMPILE ERROR because T
3// might not be String at runtime.
4interface Processor<T> {
5 default void printLength(T input) {
6 System.out.println(input.length()); // COMPILE ERROR
7 // length() is not a method of Object - T is erased to Object
8 // Only Object methods (toString, equals, hashCode, getClass) are callable
9 }
10}
11
12// CORRECT - use bounded type parameter if specific methods are needed,
13// or work only with Object-level methods
14interface StringProcessor {
15 default void printLength(String input) { // concrete type - String methods available
16 System.out.println(input.length());
17 }
18}
19
20// Or bounded - allows any CharSequence method
21interface CharSequenceProcessor<T extends CharSequence> {
22 default void printLength(T input) {
23 System.out.println(input.length()); // CharSequence has length()
24 }
25}Mistake 3 - Adding a Second Abstract Method to a Lambda-Target Interface
1import java.lang.annotation.*;
2
3// A generic interface intended for lambda use
4@FunctionalInterface
5interface Filter<T> {
6 boolean test(T item);
7
8 // Adding a SECOND abstract method breaks every lambda that targets Filter<T>
9 // COMPILE ERROR when @FunctionalInterface is present:
10 // "Multiple non-overriding abstract methods found in interface Filter"
11 // boolean testWithContext(T item, String context); <- would be COMPILE ERROR
12
13 // Default methods are fine - they do not count as abstract
14 default Filter<T> negate() {
15 return item -> !test(item);
16 }
17}
18
19// CORRECT - the @FunctionalInterface annotation protects the interface.
20// The second abstract method is caught at the interface declaration,
21// not silently at every lambda call site that now fails to compile.Mistake 4 - Forgetting That Implementing a Generic Interface Fixes the Type Argument
1// WRONG ASSUMPTION - a developer might think they can call
2// validate() with any type because the interface declared it as T.
3// Once the class fixes T to UpiRequest, the compiler enforces it.
4class UpiGatewayFixed implements PaymentGateway<UpiRequest> {
5 @Override
6 public boolean validate(UpiRequest request) { return true; }
7
8 @Override
9 public PaymentResult process(UpiRequest request) {
10 return PaymentResult.success("TXN-1", request.amount());
11 }
12
13 @Override
14 public String gatewayName() { return "UPI"; }
15}
16
17// WRONG - the method below tries to pass CardRequest where UpiRequest is expected
18class MistakeCaller {
19 static void demo() {
20 UpiGatewayFixed gateway = new UpiGatewayFixed();
21 CardRequest card = new CardRequest("1234567812345678", "123", "12", "2027", 1000.0, "ORD-1");
22 // gateway.validate(card); // COMPILE ERROR
23 // "incompatible types: CardRequest cannot be converted to UpiRequest"
24 // The type parameter T was fixed to UpiRequest when the class declared
25 // "implements PaymentGateway<UpiRequest>"
26 }
27}Interview Questions
Q1. What is a generic interface in Java, and how does it differ from a regular interface?
A generic interface declares one or more type parameters in angle brackets after the interface name — interface Converter<S, T> or interface Comparable<T>. Those parameters appear in the abstract method signatures as placeholders: T convert(S source) says "convert from S to T" without committing to specific types. A regular interface uses concrete types directly in its method signatures. The difference is generality: a generic interface can be implemented by multiple unrelated classes, each supplying different type arguments — String, Integer, and OrderSummary all implement Comparable<T> with T fixed to themselves. A regular interface has a fixed contract regardless of the implementing class.
Q2. What are the three ways to implement a generic interface, and when do you choose each?
The first pattern passes the type parameter through: class Box<T> implements Container<T>. The implementing class remains generic and the caller decides T when creating an instance — new Box<String>() makes T = String throughout. Choose this when the implementation is general-purpose and should work for any type. The second pattern fixes the type argument: class UpperCaseTransformer implements Transformer<String>. The class is concrete — it is always a Transformer<String>. Choose this when the implementation is specific to one type and has no use with other types. The third pattern is a lambda expression when the interface has exactly one abstract method: Transformer<String> t = s -> s.toUpperCase(). Choose lambdas for short, inline, one-off implementations.
Q3. Why can't a class implement the same generic interface twice with different type arguments?
Because of type erasure: at the bytecode level, both Converter<String, Integer> and Converter<String, Double> erase to the same raw type Converter. A class file cannot contain two implementations of the same erased interface — the method signatures after erasure (Object convert(Object)) would collide. The compiler catches this at the source level and rejects the declaration with an error about implementing the same interface more than once. The workaround is two separate classes or two differently named interfaces.
Q4. What is the relationship between generic interfaces and functional interfaces, and how do lambdas work with them?
A functional interface is a generic or non-generic interface with exactly one abstract method. When a generic interface has one abstract method — like Predicate<T> with test(T t), or Function<T,R> with R apply(T t) — a lambda expression is automatically an implementation of that interface for whatever type argument is declared at the usage site. The compiler substitutes the type argument into the single abstract method signature and verifies the lambda's parameter type and return type match. Adding @FunctionalInterface to the interface causes the compiler to reject any accidental addition of a second abstract method, protecting every existing lambda from silently breaking.
Q5. How does implementing Comparable
implements Comparable<String> tells the compiler that compareTo() accepts exactly String. The compiler verifies both sides of every comparison: the object compared and the argument to compareTo() must both be String. implements Comparable (raw type) means compareTo() accepts Object, requires an explicit cast inside the method, and produces unchecked warnings. The raw type also prevents the class from being used with APIs that require T extends Comparable<T> as a bound, because the raw implementation does not satisfy the bounded constraint. Using raw Comparable is always the wrong choice in new code.
Q6. Can a generic interface have default methods that use the type parameter, and how do they behave?
Yes. Default methods in a generic interface can use the interface's type parameters in their signatures and bodies. default T transformAndLog(T input) in Transformer<T> uses T in both the parameter and the return type. When a class implements Transformer<String>, the default method transformAndLog(String input) is inherited with T = String already substituted. The default method body interacts with the type through the same Object-based mechanism as abstract methods — after erasure, T becomes Object in the bytecode, and the compiler inserts any necessary casts. Implementing classes can override a default method exactly as they would override an abstract method.
FAQs
Can a generic interface have static methods, and can those methods use the interface's type parameter?
A generic interface can have static methods, but those methods cannot use the interface's own type parameter — for the same reason static members of a generic class cannot. Static methods belong to the interface itself, not to any parameterization of it. A static method can declare its own independent type parameter: static <T> Transformer<T> identity() works, where T here is the static method's own parameter, not the interface's T. This is the pattern used by Comparator.comparing(keyExtractor) and Function.identity() in the JDK.
What happens when a generic interface extends a non-generic interface?
The generic interface inherits the non-generic interface's contract alongside its own. The implementing class must satisfy both. For example, if Sortable<T> extends Serializable (non-generic), any class implementing Sortable<Order> must also be Serializable. The two are completely independent — the non-generic parent contributes no type parameters, and the generic child's type parameter has no effect on the non-generic parent's methods.
Can a generic interface declare a type parameter with a bound?
Yes. interface Sortable<T extends Comparable<T>> restricts T to types that are comparable with themselves. The bound appears in the type parameter declaration, not in the method signatures (though it may appear there too). The bound is enforced at every usage site: Sortable<String> compiles because String implements Comparable<String>, but Sortable<Object> does not compile because Object does not implement Comparable<Object>.
Is there a performance difference between implementing a generic interface versus a concrete interface?
No meaningful difference. After type erasure, both compile to the same bytecode structure. Method dispatch through an interface (invokeinterface bytecode instruction) works identically for generic and concrete interfaces. The type argument exists only in the compiler's checking phase. The JIT compiler applies the same optimizations — including inlining — to interface method calls regardless of whether the interface was generic at the source level.
Can a record implement a generic interface?
Yes. A record can implement any number of interfaces, including generic ones. record OrderSummary(String id, double amount) implements Comparable<OrderSummary> is valid and commonly written. The record's canonical constructor, accessor methods, and generated equals(), hashCode(), and toString() are independent of the interface implementation. The methods required by the interface must be explicitly implemented in the record body, just as in any class.
Can an annotation type extend a generic interface?
No. Annotation types (@interface) implicitly extend java.lang.annotation.Annotation and cannot extend any other interface, generic or otherwise. The @interface declaration syntax does not support an extends clause. Annotation types can have elements of generic types as their element types (using Class<?> or arrays), but the annotation type itself does not participate in the generic interface hierarchy.
Summary
A generic interface declares a contract parameterized by type — the implementing class or the lambda expression decides what that type actually is. The interface defines the shape; the type argument defines the scope. Comparable<T> says "I can compare to another T" without knowing what T is; String implements Comparable<String> fills in that T and makes the contract specific. Converter<S,T> says "I convert from S to T"; a lambda s -> Integer.parseInt(s) fills in S=String and T=Integer at the usage site.
Three rules govern every decision in this topic. First, a class cannot implement the same generic interface twice with different type arguments — erasure makes both implementations identical bytecode, and the conflict is rejected at compile time. Second, default methods in generic interfaces use the type parameter the same way abstract methods do — callers receive them with T already resolved to their declared type argument. Third, a single-abstract-method generic interface is automatically a lambda target — the compiler substitutes the type argument into the method signature and verifies the lambda against it.
The interfaces you use most often in Java — Comparable<T>, Iterable<E>, Predicate<T>, Function<T,R>, Supplier<T>, Consumer<T> — are all generic interfaces. Understanding that they are generic interfaces, not magic, means understanding exactly why Predicate<String> accepts a lambda with a String parameter, why Iterable<CartItem> makes for (CartItem item : collection) work, and why Comparable<T> makes sorting self-contained rather than requiring an external comparator.
What to Read Next
Learn how to write a method that works with any data type.