DEV Community

Cover image for Java Generics to TypeScript: extends, super, and the in/out Keywords
Gabriel Anhaia
Gabriel Anhaia

Posted on

Java Generics to TypeScript: extends, super, and the in/out Keywords


You come from Java. You spent years learning when to write
List<? extends Number> and when to write
List<? super Integer>. You memorised PECS — producer extends,
consumer super — and it became muscle memory. Then you open a
TypeScript file and reach for the wildcard, and there is no
wildcard. There is no ?. There is no super on a type
parameter.

Your first instinct is that TypeScript is missing a feature.
It isn't. TypeScript moved the whole problem somewhere else.
In Java, variance lives at the use site: you annotate the
wildcard every time you write a method that takes a List. In
TypeScript, variance is mostly inferred from structure, and
when you do annotate it, you annotate the declaration site
with in and out.

This post maps the Java model onto the TypeScript one, term by
term, so the knowledge you already have transfers instead of
fighting you.

The Java model, in one paragraph

Java generics are invariant by default. List<Integer> is not
a List<Number>, even though Integer is a Number. To get
covariance or contravariance you use a wildcard at the point of
use:

// covariant: read Numbers out, can't write
void sum(List<? extends Number> xs) { ... }

// contravariant: write Integers in, can't read typed
void fill(List<? super Integer> xs) { ... }
Enter fullscreen mode Exit fullscreen mode

? extends Number is a producer. You can read Number out of
it; you cannot add anything, because the real list might be
List<Double>. ? super Integer is a consumer. You can add
Integer into it; you cannot read anything more specific than
Object out. That is PECS.

The annotation is on every method signature. The list type
itself, List<T>, does not declare whether it is covariant.

TypeScript's default: structural, and variance is inferred

TypeScript has no use-site wildcards. Assignability is
structural — if the shape fits, the assignment is allowed —
and the compiler works out variance from how a type parameter
is used inside the type.

Take a read-only container. Used only in output position, it
is covariant for free:

interface Box<T> {
  get(): T;
}

const nums: Box<number> = { get: () => 1 };
const vals: Box<number | string> = nums;
// ok: Box<number> is assignable to Box<number | string>
Enter fullscreen mode Exit fullscreen mode

Box<number> flows into Box<number | string> because T
only ever comes out. That is the ? extends case, except
you wrote no wildcard. The covariance fell out of the structure.

Now put T in input position:

interface Sink<T> {
  put(x: T): void;
}

const anySink: Sink<number | string> = {
  put: (x) => console.log(x),
};
const numSink: Sink<number> = anySink;
// ok: Sink<number | string> assignable to Sink<number>
Enter fullscreen mode Exit fullscreen mode

This is the ? super case. A sink that accepts number |
string
can stand in wherever a sink of number is needed,
because it accepts everything the narrower one does. The
direction flipped on its own. That is contravariance, inferred.

Where Java's invariance shows up: read and write together

A List<T> has both get and add. T is in input and
output position, so it is invariant — exactly like Java's
default. TypeScript reaches the same conclusion structurally:

interface MutableList<T> {
  get(i: number): T;
  add(x: T): void;
}

declare const ints: MutableList<number>;
const widened: MutableList<number | string> = ints;
// error: Types of property 'add' are incompatible.
Enter fullscreen mode Exit fullscreen mode

The assignment fails for the same reason it fails in Java:
hand someone a MutableList<number | string> backed by a
MutableList<number> and they will add("hi") into a list
that only holds numbers. The compiler refuses. You did not
write invariant anywhere; the read-plus-write shape forced it.

The catch: method parameters are bivariant

There is one place the structural model is looser than Java,
and JVM developers trip on it. Method parameters typed in the
shorthand form are checked bivariantly by default, not
contravariantly. TypeScript made that choice deliberately for
ergonomics around things like event handlers and arrays.

interface Handler<T> {
  handle(x: T): void;        // method shorthand
}

declare const animalH: Handler<Animal>;
const dogH: Handler<Dog> = animalH;   // ok
const back: Handler<Animal> = dogH;   // also ok (!)
Enter fullscreen mode Exit fullscreen mode

Both directions pass. That second assignment is unsound. To
get strict contravariant checking on parameters, turn on
strictFunctionTypes and write the property in function-type
form, not method-shorthand form:

interface Handler<T> {
  handle: (x: T) => void;    // property, not method
}
Enter fullscreen mode Exit fullscreen mode

With strictFunctionTypes on and the function-property form,
Handler<Animal> no longer assigns to Handler<Dog>, and the
contravariance matches what a Java developer expects. The
method-shorthand escape hatch staying bivariant is documented
behaviour, not a bug, but it is the single biggest surprise in
this whole mapping.

Declaration-site variance: in and out

TypeScript 4.7 added explicit variance annotations on type
parameters. This is the closest analogue to Java, except it
sits at the declaration, the way Kotlin and Scala do it, not at
each use:

interface Producer<out T> {
  next(): T;
}

interface Consumer<in T> {
  accept(x: T): void;
}

interface Channel<in out T> {
  next(): T;
  accept(x: T): void;
}
Enter fullscreen mode Exit fullscreen mode

out T declares covariance. in T declares contravariance.
in out T declares invariance. These do not change
assignability — TypeScript already inferred all of this from
structure. What they do is two things worth having.

First, they are a contract check. If you write out T but then
use T in an input position, the compiler errors:

interface Bad<out T> {
  set(x: T): void;
}
// error: Type 'Bad<T>' is not assignable...
// 'T' is declared as covariant but used contravariantly.
Enter fullscreen mode Exit fullscreen mode

That catches a mismatch between what you meant and what you
wrote — the same discipline Java's wildcards enforce, moved to
the declaration.

Second, on large recursive generic types, the annotations let
the compiler skip the structural variance computation and just
trust the annotation. That is a real compile-time saving on
deep type graphs.

The mapping back to Java:

Java (use-site) TypeScript
? extends T (producer) covariant — inferred, or out T
? super T (consumer) contravariant — inferred, or in T
List<T> invariant read+write shape, or in out T
wildcard on every method declaration-site in/out once

Bounded type parameters survive almost unchanged

The one piece of Java syntax that transfers nearly verbatim is
the bound. Java's <T extends Number> becomes TypeScript's
<T extends number>, and extends means the same thing: an
upper bound the argument must satisfy.

function maxBy<T extends { value: number }>(
  xs: readonly T[],
): T | undefined {
  return xs.reduce(
    (best, x) =>
      best && best.value >= x.value ? best : x,
    undefined as T | undefined,
  );
}
Enter fullscreen mode Exit fullscreen mode

T extends { value: number } is a structural bound: any object
with a numeric value qualifies, no implements clause needed.
That is the part that feels foreign at first and then better
later. In Java you bound by a named type or interface. In
TypeScript you bound by shape, so an ad-hoc object literal
satisfies the constraint without declaring anything.

There is no lower bound on a type parameter — TypeScript has no
T super X declaration form. When you need that direction, you
express it with conditional types or with a contravariant
position in the signature, not with a keyword on T.

What survives, in one list

  • PECS intuition survives. Producers are covariant, consumers are contravariant. You still reason the same way; you just stop writing the wildcard.
  • extends as an upper bound survives, and gets more flexible because the bound is structural.
  • Invariance survives wherever a type both reads and writes its parameter — the compiler derives it.
  • Use-site wildcards do not survive. There is no ?, no ? extends, no ? super.
  • super as a type-parameter bound does not survive.
  • Declaration-site variance arrives as in and out, the Kotlin-style model, optional because inference handles the common cases.
  • One new hazard appears: bivariant method parameters. Reach for strictFunctionTypes and function-property syntax when soundness matters.

The short version: Java made you annotate variance at the call
site every time; TypeScript reads it off the structure and lets
you confirm it once at the declaration. The mental model you
built over years of Java mostly ports. You are deleting syntax,
not learning a new theory.


If this mapping helped and you want the rest of it — null
safety against Java's Optional, sealed classes becoming
discriminated unions, coroutines becoming async/await — that
is the spine of Kotlin and Java to TypeScript. It is written
for developers who already think in types and want the bridge,
not a from-scratch tour.

The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.

  1. TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
  2. The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
  4. PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
  5. TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)