Angle Bracket in Java (Generics) with Practical Examples

When I review Java codebases in 2026, one tiny pair of characters still tells me a lot about a team’s engineering discipline: . In older code, I’ll see List without a type, casting sprinkled everywhere, and the occasional production bug that only shows up under load because a String was quietly treated as a CustomerId. In newer code, I see List, method signatures that carry intent, and far fewer “how did this even compile?” moments.\n\nThose angle brackets are the entry point to Java generics: a way to make types part of your API instead of trivia in documentation. If you already “know” generics, my goal here is to make you faster at reading and writing modern Java by focusing on the patterns I reach for most: generic classes, generic methods, the diamond operator, bounds, wildcards, and the failure modes that still bite experienced developers.\n\nBy the end, you should be able to look at in any Java snippet and quickly answer: what type is flowing through this code, what guarantees the compiler is enforcing, where the guarantees stop at runtime, and how to design generic APIs that are pleasant to call (not puzzles).\n\n## What Means in Java (and Why It Exists)\n\nAngle brackets in Java attach a type argument to a class, interface, constructor call, or method. The simplest mental model I use is:\n\n- T is a placeholder for “some reference type.”\n- List is a promise that every element in the list is T.\n\nSo when you write:\n\n- List you’re saying “a list that holds only String.”\n- Map you’re saying “a mapping from CustomerId to Invoice.”\n\nThat might sound like basic compile-time checking, but the real win is that you push correctness left: the compiler becomes your teammate that blocks a whole category of mistakes while you type.\n\nA few facts that keep you out of trouble:\n\n- Generics are for reference types, not primitives. Use Integer, not int.\n- Most generic type information is enforced at compile time and largely disappears at runtime (type erasure).\n- The standard library expects generics everywhere: collections, streams, Optional, functional interfaces, and many concurrency types.\n\nCommon shapes you’ll see:\n\n // a type parameter named T\n // a concrete type argument\n <List> // nesting generics\n // wildcard with an upper bound\n // wildcard with a lower bound\n\nWhen I’m coaching teams, I don’t frame this as “use generics everywhere.” I frame it as: make incorrect states hard (or impossible) to represent in the code that matters.\n\n## Generic Classes: Turning into Safer Data Structures\n\nA generic class is the most direct way to understand angle brackets. You define a class with a type parameter, and you use that parameter in fields and methods.\n\nHere’s a small, runnable example: a TypedValue that holds exactly one value of type T.\n\n public final class TypedValue {\n private final T value;\n\n public TypedValue(T value) {\n this.value = value;\n }\n\n public T get() {\n return value;\n }\n }\n\n class TypedValueDemo {\n public static void main(String[] args) {\n TypedValue retryCount = new TypedValue(3);\n System.out.println(retryCount.get() + 1);\n\n TypedValue environmentName = new TypedValue("staging");\n System.out.println(environmentName.get().toUpperCase());\n\n // Uncommenting this line fails at compile time (which is exactly what you want):\n // TypedValue broken = new TypedValue("not a number");\n }\n }\n\nNo casts. No runtime surprises. The type relationship is obvious at the call site.\n\n### A realistic generic interface: Repository\n\nIn real systems, generics shine when you can express domain rules. A small Repository abstraction is a common example: it’s not fancy, but it prevents mistakes.\n\n import java.util.;\n\n public interface Repository {\n Optional findById(Id id);\n T save(T entity);\n void deleteById(Id id);\n }\n\n record CustomerId(String value) {}\n record Customer(CustomerId id, String emailAddress) {}\n\n final class InMemoryCustomerRepository implements Repository {\n private final Map storage = new HashMap();\n\n @Override\n public Optional findById(CustomerId id) {\n return Optional.ofNullable(storage.get(id));\n }\n\n @Override\n public Customer save(Customer customer) {\n storage.put(customer.id(), customer);\n return customer;\n }\n\n @Override\n public void deleteById(CustomerId id) {\n storage.remove(id);\n }\n\n public static void main(String[] args) {\n Repository repo = new InMemoryCustomerRepository();\n\n CustomerId id = new CustomerId("cust1001");\n repo.save(new Customer(id, "[email protected]"));\n\n String email = repo.findById(id)\n .map(Customer::emailAddress)\n .orElse("missing");\n\n System.out.println(email);\n\n // This kind of mistake becomes impossible:\n // repo.findById("cust1001");\n }\n }\n\nIf Id were a raw String everywhere, it would be easy to pass an invoice id where a customer id is expected. With CustomerId, the compiler blocks that class of bug.\n\n### When I avoid creating generic classes\n\nGeneric classes are powerful, but they can also create friction. I avoid generics when:\n\n- the type parameter doesn’t add safety (it always ends up being the same type)\n- the API becomes harder to read than the concrete alternative\n- callers fight type inference and end up writing explicit type arguments everywhere\n\nA good smell test: if code reviews keep adding comments like “what is T here?”, your abstraction may be too broad.\n\n## Generic Methods: One Algorithm, Many Types\n\nGeneric methods let you write one implementation that works across types without making the entire class generic. The syntax is easy to miss at first:\n\n- static T methodName(...) means “this method has a type parameter T.”\n- the appears before the return type.\n\nHere’s a small method I use a lot: pick the first non-null value.\n\n public final class FirstNonNull {\n private FirstNonNull() {}\n\n public static T firstNonNull(T primary, T fallback) {\n return primary != null ? primary : fallback;\n }\n\n public static void main(String[] args) {\n String region = firstNonNull(System.getenv("REGION"), "us-east-1");\n System.out.println(region);\n\n Integer port = firstNonNull(null, 8080);\n System.out.println(port);\n }\n }\n\nWhat matters here is what you don’t see:\n\n- no at the call site (type inference handles it)\n- no casts\n\n### Two type parameters: mapping one type to another\n\nA lot of production code is “take a list of X and produce a list of Y.” Here’s a complete example using Function.\n\n import java.util.;\n import java.util.function.Function;\n\n public final class ListMapper {\n private ListMapper() {}\n\n public static List mapList(List input, Function mapper) {\n List output = new ArrayList(input.size());\n for (A item : input) {\n output.add(mapper.apply(item));\n }\n return output;\n }\n\n record InvoiceNumber(String value) {}\n record Invoice(InvoiceNumber number, long totalCents) {}\n\n public static void main(String[] args) {\n List invoices = List.of(\n new Invoice(new InvoiceNumber("INV-1001"), 12500),\n new Invoice(new InvoiceNumber("INV-1002"), 9900)\n );\n\n List invoiceNumbers = mapList(invoices, inv -> inv.number().value());\n System.out.println(invoiceNumbers);\n }\n }\n\nIf you’re used to streams, you might think “why not invoices.stream().map(...).toList()?” You often should. I still like showing the loop because it makes the generic flow (A to B) extremely concrete.\n\n### “But I passed a primitive” — what’s really happening\n\nYou can’t write List, but you can call firstNonNull(null, 8080) because 8080 autoboxes to an Integer. That convenience is great for normal code, but if you’re processing millions of numbers in tight loops, boxing can create allocations and GC pressure. In those hot paths, I keep the generic layer at the edges and use primitives internally.\n\n## The Diamond Operator and Modern Type Inference\n\nYou’ll often see used with nothing inside it: new ArrayList(). That’s the diamond operator, and it tells the compiler: “infer the type arguments from the context.”\n\n import java.util.;\n\n class DiamondDemo {\n public static void main(String[] args) {\n List serviceNames = new ArrayList();\n serviceNames.add("billing-api");\n serviceNames.add("identity-service");\n\n System.out.println(serviceNames);\n }\n }\n\nOlder code often repeats the type: new ArrayList(). It still works, but the diamond keeps instantiation readable.\n\n### Traditional vs modern calling styles\n\nI encourage teams to prefer the most readable style that still communicates intent.\n\n

Task

Older style you still see

Modern style I write today

\n

\n

Create a list

List names = new ArrayList();

List names = new ArrayList();

\n

Create a map

Map counts = new HashMap();

Map counts = new HashMap();

\n

Local variable inference

List ids = fetchIds();

var ids = fetchIds(); (when fetchIds() is explicit)

\n\nIn 2026, IDEs make it easy to hover and see inferred types, and AI-assisted autocomplete usually gets generic signatures right. That can tempt teams into writing very dense generic code because “the tool understands it.” I still recommend writing for humans first:\n\n- prefer meaningful domain types (CustomerId, InvoiceNumber) instead of recycling String\n- keep nested generics shallow in public APIs\n- if inference gets confusing, introduce a named variable so the type becomes obvious\n\n## Bounds and Wildcards: Teaching Some Rules (extends, super, ?)\n\nSo far, T could be anything. Bounds let you constrain it.\n\n### Upper bounds: \n\nUpper bounds are the most common: “T must be Something or a subtype.” A classic example is requiring comparability.\n\n public final class MaxValue {\n private MaxValue() {}\n\n public static <T extends Comparable> T max(T left, T right) {\n return left.compareTo(right) >= 0 ? left : right;\n }\n\n public static void main(String[] args) {\n System.out.println(max(10, 20));\n System.out.println(max("alpha", "beta"));\n }\n }\n\nThis won’t compile for types that don’t implement Comparable, and that’s the point: you’re encoding a requirement in the type system.\n\nYou can also use multiple bounds (one class, then interfaces):\n\n interface HasMetricName {\n String metricName();\n }\n\n record LatencySample(String metricName, long millis)\n implements HasMetricName, Comparable {\n\n @Override\n public int compareTo(LatencySample other) {\n return Long.compare(this.millis, other.millis);\n }\n }\n\n final class MetricChooser {\n public static <T extends HasMetricName & Comparable> T maxByComparable(T a, T b) {\n return a.compareTo(b) >= 0 ? a : b;\n }\n\n public static void main(String[] args) {\n LatencySample max = maxByComparable(\n new LatencySample("p95.login", 120),\n new LatencySample("p95.login", 95)\n );\n System.out.println(max.metricName() + " -> " + max.millis());\n }\n }\n\nMultiple bounds are valid, but if I see them frequently in a codebase, I ask whether the abstraction is trying to do too much.\n\n### Wildcards: ? extends vs ? super (PECS)\n\nWildcards are where generics start to feel “hard,” so I ground it with one rule:\n\n- If you only read values as T, use ? extends T.\n- If you only write values of T, use ? super T.\n\nPeople call this PECS: Producer Extends, Consumer Super.\n\nProducer example: average a list of numbers.\n\n import java.util.;\n\n public final class NumberAverages {\n private NumberAverages() {}\n\n public static double average(List numbers) {\n if (numbers.isEmpty()) return 0.0;\n\n double sum = 0.0;\n for (Number n : numbers) {\n sum += n.doubleValue();\n }\n return sum / numbers.size();\n }\n\n public static void main(String[] args) {\n System.out.println(average(List.of(10, 20, 30)));\n System.out.println(average(List.of(1.5, 2.5, 3.0)));\n }\n }\n\nWith List, you can safely read Number, but you can’t add a new Integer or Double (except null) because the exact element type is unknown.\n\nConsumer example: add events into a list.\n\n import java.time.Instant;\n import java.util.;\n\n sealed interface AuditEvent permits InvoiceCreated, InvoicePaid {\n Instant occurredAt();\n }\n\n record InvoiceCreated(String invoiceNumber, Instant occurredAt) implements AuditEvent {}\n record InvoicePaid(String invoiceNumber, Instant occurredAt) implements AuditEvent {}\n\n public final class AuditEvents {\n private AuditEvents() {}\n\n public static void addInvoiceEvents(List sink, String invoiceNumber) {\n sink.add(new InvoiceCreated(invoiceNumber, Instant.now()));\n sink.add(new InvoicePaid(invoiceNumber, Instant.now()));\n }\n\n public static void main(String[] args) {\n List events = new ArrayList();\n addInvoiceEvents(events, "INV-2001");\n System.out.println(events);\n }\n }\n\nBecause the list is a consumer, ? super AuditEvent gives you freedom to add AuditEvent instances.\n\nIf you’re designing APIs, my practical advice is: use wildcards when they make calling easier, but don’t sprinkle them everywhere “just because it’s correct.” If the signature becomes intimidating, it’s often better to introduce a named type parameter to make the relationship clearer.\n\n## Type Erasure: Where Stops Existing at Runtime\n\nHere’s the part that still surprises people: Java generics are implemented with type erasure. The JVM usually does not keep generic type arguments at runtime.\n\nThat leads to rules like:\n\n- you can’t do new T() inside a generic class\n- you can’t create new List[10]\n- reflection often sees List and loses the String part unless you carry extra information\n\nA tiny example that makes erasure obvious: two lists with different type arguments have the same runtime class.\n\n import java.util.;\n\n public final class ErasureDemo {\n public static void main(String[] args) {\n List regions = new ArrayList();\n List ports = new ArrayList();\n\n System.out.println(regions.getClass() == ports.getClass());\n System.out.println(regions.getClass().getName());\n }\n }\n\nThis prints true and then something like java.util.ArrayList. At runtime, both are just ArrayList. The and were compile-time constraints.\n\n### Why you can’t do new T() (and what I do instead)\n\nA very common “first generic class” mistake is trying to instantiate T. For example: a container that wants to create default instances. Java can’t do it because it doesn’t know what T is at runtime.\n\nWhen I need “create a T,” I pass in a factory: usually a Supplier.\n\n import java.util.function.Supplier;\n\n public final class Lazy {\n private final Supplier factory;\n private T value;\n\n public Lazy(Supplier factory) {\n this.factory = factory;\n }\n\n public T get() {\n if (value == null) {\n value = factory.get();\n }\n return value;\n }\n\n public static void main(String[] args) {\n Lazy buffer = new Lazy(StringBuilder::new);\n buffer.get().append("hello");\n System.out.println(buffer.get().toString());\n }\n }\n\nThis is a pattern I like because it stays explicit: callers decide how to construct T, and the generic type stays honest.\n\n### “But frameworks can read generic types” — yes, with extra metadata\n\nSome libraries preserve generic type information by forcing you to capture it in a subclass (or a special “type token”). Even if you’re not using a framework, the concept is useful: erasure is real, so if you need the type at runtime, you must carry it yourself.\n\nA simple approach is to pass a Class around when you need runtime checks or safe casts.\n\n public final class SafeCast {\n private SafeCast() {}\n\n public static T cast(Object value, Class type) {\n return type.cast(value);\n }\n\n public static void main(String[] args) {\n Object x = "us-east-1";\n String region = cast(x, String.class);\n System.out.println(region);\n\n // This throws a ClassCastException with a clearer message/location than a raw cast: \n // Integer bad = cast(x, Integer.class);\n }\n }\n\nIt’s not that Class “solves generics.” It solves a different problem: when you truly need runtime type behavior, you model that explicitly rather than pretending erasure doesn’t exist.\n\n## Raw Types: The Shortcut That Creates Long-Term Pain\n\nWhen you see code like List list = new ArrayList();, that’s a raw type. It disables most generic checking and pushes errors to runtime.\n\nHere’s a minimal example of how raw types smuggle bugs past the compiler:\n\n import java.util.;\n\n public final class RawTypeBug {\n public static void main(String[] args) {\n List names = new ArrayList(); // raw List\n names.add("Ada");\n names.add("Linus");\n\n // Somewhere far away in the codebase…\n names.add(123);\n\n // Now this compiles but fails at runtime:\n for (Object o : names) {\n String s = (String) o;\n System.out.println(s.toUpperCase());\n }\n }\n }\n\nThis is exactly the class of problem generics were designed to prevent. If you change the first line to List names = new ArrayList();, the accidental names.add(123); becomes a compile-time error.\n\n### My rule of thumb for warnings\n\nIf the compiler says “unchecked” or “raw type,” I treat it as a small leak. Sometimes you accept it at a boundary (interop with legacy code, reflection, deserialization), but I don’t let it spread.\n\nIf I must suppress warnings, I keep the suppression local and justified. I’d rather have one narrow, well-reviewed “unsafe” boundary than a codebase that’s quietly ignoring type safety everywhere.\n\n## Invariance: Why List Is Not a List\n\nOne of the most important (and most misunderstood) rules behind is invariance. Even if Dog extends Animal, List does not extend List.\n\nI explain it with the “write problem.” If Java allowed this:\n\n- treat a List as a List\n\nthen someone could add a Cat to your dog list through the List view. That would break the guarantee that the original list contains only Dog.\n\nHere’s what actually works: if you want to accept “a list of any subtype of Animal,” you use a wildcard: List.\n\n import java.util.;\n\n class Animal {}\n class Dog extends Animal {}\n class Cat extends Animal {}\n\n public final class InvarianceDemo {\n public static void printAnimals(List animals) {\n for (Animal a : animals) {\n System.out.println(a.getClass().getSimpleName());\n }\n\n // animals.add(new Dog()); // does not compile\n }\n\n public static void main(String[] args) {\n List dogs = List.of(new Dog(), new Dog());\n printAnimals(dogs);\n\n List cats = List.of(new Cat());\n printAnimals(cats);\n }\n }\n\nThis is the same “producer” idea from PECS: animals produces Animal values for you to read, but you can’t safely write into it.\n\nWhen I design APIs, I treat invariance as a forcing function: it nudges me to clarify whether a parameter is an input source, an output sink, or both.\n\n## Practical API Design: Making Generic Code Pleasant to Call\n\nYou can write perfectly “correct” generic signatures that are miserable to use. The goal isn’t to impress the compiler. The goal is to make the call site obvious. Here are patterns I rely on.\n\n### Prefer generic factory methods for inference\n\nSometimes constructors don’t infer as cleanly as static factories. A static method often yields nicer call sites.\n\n import java.util.;\n\n public final class Lists {\n private Lists() {}\n\n @SafeVarargs\n public static List of(T… items) {\n List out = new ArrayList(items.length);\n Collections.addAll(out, items);\n return List.copyOf(out);\n }\n\n public static void main(String[] args) {\n List ports = Lists.of(8080, 8081, 9090);\n System.out.println(ports);\n\n List regions = Lists.of("us-east-1", "us-west-2");\n System.out.println(regions);\n }\n }\n\n(Yes, the JDK already has List.of(...). The point is the pattern: generic static factories are inference-friendly and ergonomic.)\n\n### Use wildcards at the boundary, type parameters inside\n\nA signature like void copy(List dst, List src) is correct, but it’s also intimidating. When I can, I hide the wildcard complexity behind a named method and let callers just pass their lists.\n\n import java.util.;\n\n public final class CopyUtil {\n private CopyUtil() {}\n\n public static void copy(List dst, List src) {\n for (T item : src) {\n dst.add(item);\n }\n }\n\n public static void main(String[] args) {\n List numbers = new ArrayList();\n List ints = List.of(1, 2, 3);\n copy(numbers, ints);\n System.out.println(numbers);\n }\n }\n\nCallers don’t have to understand PECS to benefit from it.\n\n### Avoid returning wildcards in public APIs\n\nAs a general rule, I avoid return types like List in public APIs unless I have a strong reason. It can leak complexity into every caller. Usually, returning a concrete List (or an interface like Collection) is more ergonomic.\n\nIf you truly need “some subtype” semantics, consider whether a different abstraction is better (an interface, a sealed hierarchy, or a visitor pattern) rather than returning a wildcarded collection.\n\n## Common Pitfalls (and How I Debug Them Fast)\n\n### Pitfall 1: “Why can’t I add to List?”\n\nBecause you don’t know the exact element type. It might be a List or a List. Allowing you to add a Double would break List.\n\nWhen I hit this in real code, I ask: is this parameter a producer or a consumer?\n\n- Producer: keep ? extends and don’t add.\n- Consumer: change to ? super.\n- Both: use a type parameter like and accept a List (or redesign the method).\n\n### Pitfall 2: “My overloads break type inference”\n\nGenerics plus overloaded methods can become ambiguous quickly, especially with lambdas. If a call site becomes confusing, I’ll often fix it by:\n\n- renaming one overload to be explicit\n- adding a helper method with explicit type parameters\n- introducing a local variable so the target type is clear\n\nClarity beats cleverness.\n\n### Pitfall 3: Heap pollution with generics + varargs\n\nA subtle corner of is that generic arrays don’t really exist cleanly (because arrays are reified at runtime, but generics are erased). Varargs (T...) is implemented as an array, so you can get warnings about “heap pollution.”\n\nIn safe cases, I use @SafeVarargs (only where legal) and keep the method simple: don’t store the varargs array anywhere, don’t mutate it, and don’t expose it.\n\n### Pitfall 4: “Unchecked cast” at boundaries\n\nSometimes you must cast: deserialization, reflection, legacy APIs. When I see an unchecked cast, I try to convert it into a checked cast using a Class token (or validate element types in a loop) so the failure is immediate and close to the source.\n\n## in the Standard Library: Reading Modern Java Faster\n\nIf you want to get fluent with angle brackets, the best practice is reading JDK types. Here’s how I interpret common ones quickly.\n\n### Optional\n\nI treat Optional as “a value that might be missing.” The generic argument is the value type. If you see Optional, that’s not a collection—it’s a presence/absence wrapper.\n\nPractical habit I keep: I avoid Optional in fields and avoid Optional<Optional>. I use Optional for return types and pipeline operations where it improves clarity.\n\n### Function and friends\n\nFunctional interfaces are generics-heavy, but the mental model is consistent:\n\n- Function: takes A, returns B\n- Predicate: takes T, returns boolean\n- Consumer: takes T, returns nothing\n- Supplier: returns T\n\nOnce you read those as “arrows,” a lot of stream code becomes less mysterious.\n\n### CompletableFuture\n\nI read CompletableFuture as “a promise of a T later.” The generics matter because they compose: thenApply maps T to U the same way my earlier mapList maps A to B.\n\nWhen I see a CompletableFuture, I interpret it as “completion signal only,” not “returns null.” That small shift helps me reason about async control flow.\n\n## Performance Considerations: Generics, Boxing, and Hot Paths\n\nGenerics themselves don’t slow down your code in a dramatic way—they mostly disappear after compilation. What can hurt performance is what you build on top of them.\n\n### Autoboxing costs show up in tight loops\n\nIf you store numbers in List, you’ve already accepted boxing. For many services, that’s totally fine. But in hot paths (parsing massive datasets, real-time analytics, high-frequency trading simulations, etc.), boxing can add measurable overhead.\n\nWhat I do in practice:\n\n- keep public APIs generic and pleasant\n- inside performance-critical code, switch to primitives (int[], long[]) or primitive streams (IntStream, LongStream)\n- measure before optimizing; don’t guess\n\n### Prefer “generic at the edges”\n\nA pattern I like is “generic at the edges, concrete in the core.” For example: accept List at the boundary, then convert once into a primitive array if you truly need speed. That keeps your API flexible while keeping your algorithm efficient.\n\n## When NOT to Use (Yes, Sometimes)\n\nGenerics are a tool, not a virtue signal. Here are scenarios where I keep things simpler.\n\n### Don’t parameterize everything just because you can\n\nIf a class is always String-based, making it Thing doesn’t help. It forces every caller to think about T and adds noise to every type signature.\n\n### Avoid deeply nested generics in public APIs\n\nIf I see something like Map<String, List<Map<String, Set>>> in a public method signature, I pause. Even if it’s “correct,” it’s hard to reason about and easy to misuse. Usually there’s a better modeling choice: create a small domain object that carries meaning and keeps the API readable.\n\n### Prefer domain types over generic cleverness\n\nI’d rather see Money, CustomerId, InvoiceNumber, Region, and EmailAddress than a sea of String and Long. Generics make those domain types easy to thread through collections and utilities without losing safety. But generics can’t replace modeling.\n\n## A Migration Playbook: Cleaning Up a Legacy Codebase with Raw Types\n\nIf you’re staring at a codebase full of raw types, I don’t recommend trying to “genericize everything” in one massive PR. I’ve had better results with a staged approach.\n\n### Step 1: Fix the boundaries first\n\nStart where data enters and leaves your system: parsing, database access, HTTP clients, message queues. If you can type those boundaries, the rest of your code becomes easier to type safely.\n\n### Step 2: Replace raw collections with typed collections\n\nWhenever you see List or Map without type parameters, add them. This often triggers a small cascade of compile errors, but the errors are useful: they point to real ambiguity in your code.\n\n### Step 3: Eliminate casts by making APIs honest\n\nIf you see (Customer) something, ask why something wasn’t already typed as Customer. Often the fix is not “add another cast,” but “add a generic parameter to the method that returns it.”\n\n### Step 4: Keep suppressions local\n\nIf you must use @SuppressWarnings("unchecked"), keep it on the smallest possible scope (a variable or a single method), and ideally validate the data before casting.\n\n## Quick Reference: How I Read at a Glance\n\nWhen I’m scanning code quickly, I use these heuristics:\n\n- Foo: Foo is parameterized by T; track what T represents (element type, id type, payload type, etc.)\n- T method(...): method introduces a new type parameter; the call site likely drives inference\n- ? extends X: you can read X values out, but can’t safely add (producer)\n- ? super X: you can add X values in, but reading gives you Object (consumer)\n- nested generics: simplify mentally from the outside in; identify the “main container” first (List/Map/Optional/Future)\n\n## Summary: Why Angle Brackets Matter\n\nAngle brackets in Java aren’t decoration—they’re how you turn intention into enforceable constraints. The practical payoff I care about is simple: fewer runtime surprises, clearer APIs, and a codebase where “what type is this?” is answered by the compiler instead of tribal knowledge.\n\nIf you take only a few things away, I’d make them these:\n\n- Use to model intent (domain ids, payload types, element types).\n- Use the diamond operator to reduce noise, but don’t let inference hide meaning.\n- Learn PECS for wildcards, then hide that complexity behind ergonomic methods.\n- Remember erasure: if you need types at runtime, carry them explicitly (Class, factories).\n- Treat raw types and unchecked warnings as boundaries to contain, not normal code to spread.\n\nAngle brackets are small, but the discipline they enable is huge: they let you build systems where whole categories of bugs don’t exist anymore—because they can’t compile.

Scroll to Top