Skip to content
Commitwise
Menu
Programming

Generics

Write one function or type that works with many types safely, add constraints, and understand variance, type erasure and reified generics in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

You need a paged response for products. Then for orders. Then for reviews. You could copy ProductPage three times, or write one Page of Any and cast everywhere, which compiles fine and crashes at runtime when someone puts the wrong thing in.

GenericsGenericsWriting a type or function once so it works with many types, while keeping type safety.Open in glossary → are the third option: write Page<Item> once, and the compiler checks every use as if you’d written a separate type for each.

New to some of these? Open what you need, or skip ahead.

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data. TypeScript often uses plain objects described by an interface.
Basics:What is an interface (or protocol)?

An interface (Kotlin, Java) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:What are lists, sets and maps?

Collections hold many values:

  • A list (Swift Array, Kotlin List, Java List) keeps items in order, and allows duplicates: ["Ana", "Ben", "Ana"].
  • A set holds each value at most once and answers “is this in it?” very quickly.
  • A map (Swift Dictionary, Kotlin, Java and TypeScript Map) stores values under keys: "Ana" → 30, looked up by the key.

Lists use indexes starting at 0: in ["a", "b", "c"], "a" is at index 0 and "c" at index 2.

Basics:What is JSON?

JSON is the text format most APIs use to send data. Objects are written in braces with "key": value pairs, lists in square brackets:

{ "id": 7, "name": "Mug", "price": 9.5, "tags": ["kitchen", "gift"], "discount": null }

Values can be strings (in quotes), numbers, true/false, null, objects or lists. Turning JSON into your own types is called decoding (or parsing, or deserializing); the opposite is encoding.

Basics:What is boxing (int vs Integer)?

Java has fast primitive types like int, double and boolean, which aren’t objects. Collections and generics can only hold objects, so Java wraps primitives in objects: int → Integer, double → Double. That wrapping is called boxing; reading the number back out is unboxing.

Two things to know: an Integer can be null (and unboxing null crashes), and comparing two Integers with == compares the objects, not the numbers. Kotlin hides most of this (its Int becomes int or Integer as needed), and Swift has no boxing at all for its number types.

Part Not started

A placeholder for a type

Think of a labeled storage box. The box works the same whatever you put in, but the label says what’s inside: “Box of screws”, “Box of cables”. If the label says screws, nobody is allowed to put cables in, and whoever opens it knows they’ll get screws.

A generic type has a type parameter, usually called T (or a better name like Item), that works like that label:

  • you write the type once, with T wherever “the thing inside” goes,
  • each use fills in the label: Page<Product>, Page<Order>,
  • the compiler checks every use: you can’t put an Order into a Page<Product>, and you never need a cast to get a Product out.

Generics let types and functions abstract over types while keeping static checking. The four languages implement them differently, and that’s where the senior questions are: Swift generics are reified and can be specialized (no boxing, full type information at runtime), with existentials (any P) as the separate, dynamically dispatched alternative; Java and Kotlin use type erasureType erasureRemoving generic type arguments after compilation, so List<String> and List<Int> are the same class at runtime.Open in glossary → (List<String> and List<Int> are the same class at runtime), with Kotlin adding reified type parameters for inline functions. VarianceVarianceThe rules for whether a generic type of a subtype counts as a generic type of its supertype.Open in glossary → differs too: declaration-site in Kotlin (out/in), use-site wildcards in Java (? extends/? super), invariance with special-cased covariant collections in Swift. TypeScript checks generics structurally and erases them along with every other type. The Detailed version covers all of it.

struct Page<Item> {
    let items: [Item]
    let page: Int
    let hasMore: Bool

    /// Same paging information, different item type.
    func map<NewItem>(_ transform: (Item) -> NewItem) -> Page<NewItem> {
        Page<NewItem>(items: items.map(transform), page: page, hasMore: hasMore)
    }
}

/// Works for any type whose values can be compared with < and >.
func largest<T: Comparable>(_ items: [T]) -> T? {
    guard var best = items.first else { return nil }
    for item in items.dropFirst() where item > best { best = item }
    return best
}

let products = Page(items: [Product(name: "Mug", price: 9)], page: 1, hasMore: false)
let names: Page<String> = products.map(\.name)
print(largest([3, 9, 4]) ?? 0)        // 9
print(largest(["pear", "apple"])!)    // pear
data class Page<T>(val items: List<T>, val page: Int, val hasMore: Boolean) {
    /** Same paging information, different item type. */
    fun <R> map(transform: (T) -> R): Page<R> = Page(items.map(transform), page, hasMore)
}

/** Works for any type whose values can be compared. */
fun <T : Comparable<T>> largest(items: List<T>): T? {
    var best = items.firstOrNull() ?: return null
    for (item in items.drop(1)) if (item > best) best = item
    return best
}

val products = Page(listOf(Product("Mug", 9.0)), page = 1, hasMore = false)
val names: Page<String> = products.map { it.name }
println(largest(listOf(3, 9, 4)))          // 9
println(largest(listOf("pear", "apple")))  // pear
record Page<T>(List<T> items, int page, boolean hasMore) {
    /** Same paging information, different item type. */
    <R> Page<R> map(Function<? super T, ? extends R> transform) {
        return new Page<>(items.stream().<R>map(transform).toList(), page, hasMore);
    }
}

/** Works for any type whose values can be compared. */
static <T extends Comparable<? super T>> Optional<T> largest(List<T> items) {
    return items.stream().max(Comparator.naturalOrder());
}

Page<Product> products = new Page<>(List.of(new Product("Mug", 9)), 1, false);
Page<String> names = products.map(Product::name);
System.out.println(largest(List.of(3, 9, 4)).orElse(0));     // 9
System.out.println(largest(List.of("pear", "apple")).get()); // pear
interface Page<Item> {
  readonly items: Item[];
  readonly page: number;
  readonly hasMore: boolean;
}

/** Same paging information, different item type. */
function mapPage<Item, NewItem>(page: Page<Item>, transform: (item: Item) => NewItem): Page<NewItem> {
  return { ...page, items: page.items.map(transform) };
}

/** Works for any type that > can compare: numbers or strings. */
function largest<T extends number | string>(items: T[]): T | undefined {
  let best = items[0];
  for (const item of items.slice(1)) if (item > best) best = item;
  return best;
}

const products: Page<Product> = { items: [{ name: "Mug", price: 9 }], page: 1, hasMore: false };
const names: Page<string> = mapPage(products, (p) => p.name);
console.log(largest([3, 9, 4]));          // 9
console.log(largest(["pear", "apple"]));  // pear

Two kinds of generics are in that code:

  • A generic type: Page<Item> is written once and used as Page<Product>, Page<String>, and so on.
  • A generic function: largest works for numbers, strings, dates, anything comparable. The constraint (T: Comparable, T : Comparable<T>, T extends Comparable, T extends number | string) is what lets the code use >: without it, the compiler doesn’t know that two T values can be compared.

You’ll rarely need to write the type in angle brackets when using generics: Page(items: …), listOf(3, 9, 4) and largest([3, 9, 4]) let the compiler work it out from the arguments.

Practice: a generic in-memory store

Tests and previews often need a fake repository. Instead of one per type, let’s write one generic store, step by step:

  1. Name the type parameters: the item type, and the id type.
  2. Constrain the item so the store can read its id: Identifiable in Swift, a small Entity<ID> interface in Kotlin, Java and TypeScript.
  3. Store items in a map from id to item.
  4. Use it for products, orders, anything with an id, with full type checking.
final class InMemoryStore<Item: Identifiable> {
    private var items: [Item.ID: Item] = [:]

    func save(_ item: Item) { items[item.id] = item }
    func find(_ id: Item.ID) -> Item? { items[id] }
    func all() -> [Item] { Array(items.values) }
}

struct Product: Identifiable { let id: Int; let name: String }

let store = InMemoryStore<Product>()
store.save(Product(id: 1, name: "Mug"))
print(store.find(1)?.name ?? "missing")     // Mug
interface Entity<ID> { val id: ID }

class InMemoryStore<ID, T : Entity<ID>> {
    private val items = LinkedHashMap<ID, T>()

    fun save(item: T) { items[item.id] = item }
    fun find(id: ID): T? = items[id]
    fun all(): List<T> = items.values.toList()
}

data class Product(override val id: Int, val name: String) : Entity<Int>

val store = InMemoryStore<Int, Product>()
store.save(Product(1, "Mug"))
println(store.find(1)?.name ?: "missing")    // Mug
interface Entity<ID> { ID id(); }

class InMemoryStore<ID, T extends Entity<ID>> {
    private final Map<ID, T> items = new LinkedHashMap<>();

    void save(T item) { items.put(item.id(), item); }
    Optional<T> find(ID id) { return Optional.ofNullable(items.get(id)); }
    List<T> all() { return List.copyOf(items.values()); }
}

record Product(Integer id, String name) implements Entity<Integer> {}

InMemoryStore<Integer, Product> store = new InMemoryStore<>();
store.save(new Product(1, "Mug"));
System.out.println(store.find(1).map(Product::name).orElse("missing"));   // Mug
interface Entity<ID> { readonly id: ID }

class InMemoryStore<ID, T extends Entity<ID>> {
  private readonly items = new Map<ID, T>();

  save(item: T) { this.items.set(item.id, item); }
  find(id: ID): T | undefined { return this.items.get(id); }
  all(): T[] { return [...this.items.values()]; }
}

interface Product extends Entity<number> { name: string }

const store = new InMemoryStore<number, Product>();
store.save({ id: 1, name: "Mug" });
console.log(store.find(1)?.name ?? "missing");   // Mug

Swift’s Identifiable already carries the id’s type (Item.ID, an associated type), so one type parameter is enough. Kotlin, Java and TypeScript pass both explicitly. (In TypeScript, any object with a matching id fits Entity<ID>, without declaring it: generics are checked by shape.)

One more generic trap, specific to Java:

Spot the bugA contains that misses big numbers

Tap the line with the bug.

static <T> boolean containsValue(List<T> list, T value) {
    for (T item : list) {
        if (item == value) return true;
    }
    return false;
}

System.out.println(containsValue(List.of(5, 1000), 5));      // true
System.out.println(containsValue(List.of(5, 1000), 1000));   // false!

Exerciseeasy

Paginate anything

Write a generic function paginate(items, pageSize) that splits a list into pages using the Page type from the start of the tutorial: page numbers start at 1, hasMore is true on every page except the last, and an empty list gives no pages. It must work for any item type, and reject a page size below 1.

paginate(["a", "b", "c", "d", "e"], 2)
→ Page(["a", "b"], 1, true), Page(["c", "d"], 2, true), Page(["e"], 3, false)
›

The function needs one type parameter for the items. The result type is a list of Page of that same type.

›

Most standard libraries can split a list into chunks: chunked(size) in Kotlin; in Swift, stride over the indices; in Java, use subList in a loop; in TypeScript, slice in a loop.

SolutionTry it yourself firstTime O(n)Space O(n)
func paginate<Item>(_ items: [Item], pageSize: Int) -> [Page<Item>] {
    precondition(pageSize >= 1, "pageSize must be at least 1")
    let starts = Array(stride(from: 0, to: items.count, by: pageSize))
    return starts.enumerated().map { index, start in
        let end = min(start + pageSize, items.count)
        return Page(items: Array(items[start..<end]), page: index + 1, hasMore: end < items.count)
    }
}
fun <T> paginate(items: List<T>, pageSize: Int): List<Page<T>> {
    require(pageSize >= 1) { "pageSize must be at least 1" }
    val chunks = items.chunked(pageSize)
    return chunks.mapIndexed { index, chunk ->
        Page(chunk, page = index + 1, hasMore = index < chunks.lastIndex)
    }
}
static <T> List<Page<T>> paginate(List<T> items, int pageSize) {
    if (pageSize < 1) throw new IllegalArgumentException("pageSize must be at least 1");
    List<Page<T>> pages = new ArrayList<>();
    for (int start = 0, number = 1; start < items.size(); start += pageSize, number++) {
        int end = Math.min(start + pageSize, items.size());
        pages.add(new Page<>(List.copyOf(items.subList(start, end)), number, end < items.size()));
    }
    return pages;
}
function paginate<T>(items: T[], pageSize: number): Page<T>[] {
  if (pageSize < 1) throw new RangeError("pageSize must be at least 1");
  const pages: Page<T>[] = [];
  for (let start = 0; start < items.length; start += pageSize) {
    const end = Math.min(start + pageSize, items.length);
    pages.push({ items: items.slice(start, end), page: pages.length + 1, hasMore: end < items.length });
  }
  return pages;
}

The function never looks inside an item, so it needs no constraint at all: T can be anything. That’s a good sign: the fewer constraints a generic function needs, the more widely it’s reusable. Note List.copyOf in Java: subList is a view of the original list, and a page built on it would change if the original did.

Variance

If Cat is an Animal, is a list of cats a list of animals? For a read-only list, yes (covariance); for a writable one, no, or you could add a Dog to a list of cats.

  • Kotlin declares variance on the type: interface List<out E> is covariant (a List<Cat> is a List<Animal>), MutableList<E> is invariant, and in marks consumers (Comparator<in T>). Use-site projections (MutableList<out Animal>) exist for the rest.
  • Java decides at each use with wildcards: PECS, “producer extends, consumer super”: List<? extends Animal> to read animals from, List<? super Cat> to add cats to (Collections.copy(List<? super T> dest, List<? extends T> src)).
  • TypeScript compares types by shape, and infers variance from how a type parameter is used (or takes explicit in / out annotations since 4.7). Arrays are treated as covariant although they’re mutable, a deliberate hole for convenience: a Cat[] is accepted as an Animal[], and nothing stops you from pushing a Dog into it.
  • Swift generics are invariant, but the standard collections are special-cased: [Cat] converts to [Animal] (a copy, thanks to value semantics, so it’s safe). Your own Box<Cat> is not a Box<Animal>.

Is a list of cats a list of animals?

Predict the output

Cat is a subclass of Animal. Does this compile?

class Animal {}
class Cat: Animal {}

var animals: [Animal] = [Cat]()
open class Animal
class Cat : Animal()

val animals: MutableList<Animal> = mutableListOf<Cat>()
class Animal {}
class Cat extends Animal {}

List<Animal> animals = new ArrayList<Cat>();
class Animal { name = ""; }
class Cat extends Animal { meow() {} }

const cats: Cat[] = [];
const animals: Animal[] = cats;
SwiftKotlinJavaTypeScript
Generic type / functionstruct Box<T>, func f<T>(_:)class Box<T>, fun <T> f()class Box<T>, static <T> void f()interface Box<T>, function f<T>()
ConstraintT: Comparable, where clausesT : Comparable<T>, whereT extends Comparable<? super T>T extends { id: string } (structural)
Varianceinvariant (std collections covariant)out / in at declaration, projectionswildcards: ? extends read, ? super write (PECS)structural; arrays covariant (unsound); in / out annotations (4.7)

Swift

Prefer generics and some P (one hidden concrete type, checked statically) over any P (a box with dynamic dispatch, heap-allocated when the value is larger than three words) in hot code. [Cat] converts to [Animal] because arrays are values; your own Box<Cat> doesn’t.

Kotlin

List<out E> is covariant (a List<Cat> is a List<Animal>), MutableList<E> invariant, Comparator<in T> contravariant; use-site projections cover the rest.

Java

PECS: “producer extends, consumer super”: copy(List<? super T> dst, List<? extends T> src). ArrayList<Cat> is not a List<Animal>.

TypeScript

Generics are checked structurally: any type with the right shape satisfies a constraint. Arrays are covariant even though they’re mutable, so const animals: Animal[] = cats; animals.push(dog) compiles: a known hole.

Check this part

  1. What does Page<Product> guarantee?

  2. Why does largest need a constraint on its type parameter?

  3. Cat is a subclass of Animal. Does var animals: [Animal] = [Cat]() compile?Cat is a subclass of Animal. Does val animals: MutableList<Animal> = mutableListOf<Cat>() compile?Cat is a subclass of Animal. Does List<Animal> animals = new ArrayList<Cat>(); compile?Cat extends Animal. Does const animals: Animal[] = cats (a Cat[]) compile?

  4. What's the difference between some Shape and any Shape?What does out in interface List<out E> mean?A method copies animals from one list to another. Which signature follows PECS?What do in and out on a type parameter (TypeScript 4.7) do?

A type parameter is a placeholder filled in at each use; constraints say what generic code may do with it.

Part Not started

Where casts sneak back in

Generics only protect you if you use them. Here’s what happens when code steps around them:

Spot the bugA crash far away from the mistake

Tap the line with the bug.

let values: [Any] = ["42", "17"]
let numbers = values as! [Int]
let total = numbers.reduce(0, +)
print(total)
val values: List<Any> = listOf("42", "17")
@Suppress("UNCHECKED_CAST")
val numbers = values as List<Int>
val total = numbers.sum()
println(total)
List raw = new ArrayList();
raw.add("42");
List<Integer> numbers = raw;
int total = numbers.get(0);
System.out.println(total);
const values: unknown[] = ["42", "17"];
const numbers = values as number[];
const total = numbers.reduce((sum, n) => sum + n, 0);
console.log(total);

  • Never use raw types, suppressed unchecked casts or type assertions to make a compiler error go away: the failure moves to wherever the value is first used, often in someone else’s code.
  • Convert data explicitly where it enters (parse strings into numbers, decode JSON with the full type or a schema).

Swift

as! on an array checks every element at the cast, so the crash at least points at the lie.

Kotlin, Java

An unchecked cast (or a raw type) checks nothing at runtime, because type arguments are erased; the ClassCastException appears later, where an element is read.

TypeScript

as is a compile-time assertion with no runtime check at all: wrong data doesn’t even crash, it flows on (0 + "42" is "042").

Check this part

  1. A [Any] holding "42" and "17" is force-cast with as! [Int]. Where does the program fail?A List<Any> holding "42" and "17" is cast with as List<Int>. Where does the program fail?A raw List holding "42" and "17" is assigned to List<Integer>. Where does the program fail?An unknown[] holding "42" and "17" is asserted with as number[] and summed. Where does it fail?

  2. What's the right way to turn ["42", "17"] into numbers?

  3. What does as! [Int] check?Why doesn't values as List<Int> check anything at runtime?What does using a raw type (List without <…>) do?What does values as number[] check at runtime?

Raw types, force casts and unchecked casts step around generics and move the failure far from the mistake.

Part Not started

Do generics exist at runtime?

The four languages agree at compile time, but not at runtime. Swift remembers the type arguments, so [String] and [Int] are different types even while the app runs. Kotlin and Java erase them after compiling: at runtime, a List<String> and a List<Int> are both just List. TypeScript goes further and erases every type: the running JavaScript has no types at all.

That’s why on the JVM you can’t write x is List<String> (Kotlin) or x instanceof List<String> (Java), and why in TypeScript a check like “is this a string[]?” has to look at the actual values.

The languages agree at compile time and diverge at runtime: Swift generics are reified, JVM generics erased (with reified as Kotlin’s inline-only escape hatch), and TypeScript types erased completely, so every runtime question (is T, decoding T from JSON) needs an explicit runtime representation: a Class, a TypeToken, a schema.

Predict the output

What gets printed?

print([String].self == [Int].self)
println(ArrayList<String>()::class == ArrayList<Int>()::class)
System.out.println(new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass());
console.log(new Array<string>().constructor === new Array<number>().constructor);

Erasure rarely matters day to day, but it explains several rules. On the JVM: no new T(), no T.class, no overloads that differ only in type arguments (process(List<String>) and process(List<Integer>) clash), and JSON libraries needing extra help for List<User> (the senior puzzle).

Erasure, reification and existentials

  • JVM erasure: type arguments exist for the compiler only. Kotlin’s inline fun <reified T> … keeps T at the call site (so T::class, is T and typeOf<T>() work), which is how ViewModelProvider-style and JSON helpers get the type without a Class parameter.
  • TypeScript erasure is total: no types exist at runtime, so there’s no reflection and no T to ask about. Libraries that need runtime types take a value that describes them: a schema (Zod, Valibot), a class constructor, or a decoder function.
  • Swift reification: generic code can be specialized per type by the optimizer (no boxing, inlined calls) when the type is visible; across module boundaries without @inlinable, it runs through witness tables at runtime instead.
  • Swift existentials: any Shape is a box holding any conforming value, with dynamic dispatch and a heap allocation when the value is larger than three words; some Shape is a single hidden concrete type, checked statically. Prefer generics and some; use any for heterogeneous collections. Primary associated types (any Collection<Int>) make constrained existentials practical.

The type that went missing

Spot the bugUsers that aren't users

Tap the line with the bug.

inline fun <reified T> Gson.fromJson(json: String): T = fromJson(json, T::class.java)

val users: List<User> = gson.fromJson<List<User>>(body)
println(users.first().name)
static <T> T fromJson(Gson gson, String json, Class<T> type) {
    return gson.fromJson(json, type);
}

List<User> users = fromJson(gson, body, List.class);
System.out.println(users.get(0).name());
async function fetchJson<T>(url: string): Promise<T> {
  const response = await fetch(url);
  return response.json();
}

const users = await fetchJson<User[]>("/api/users");
console.log(users[0].name.toUpperCase());

Exercisemedium

A type-safe preferences API

An app stores settings (dark mode, text size, a list of muted topics) in UserDefaults / SharedPreferences / a properties store, by string key. Bugs keep happening: a value written as Int and read as String, typos in keys, defaults duplicated in five places.

Design a generic API where each setting is declared once with its name, type and default, and reading or writing it is type-checked:

prefs[Keys.darkMode]            // Bool, never a cast
prefs[Keys.textSize] = 18       // only Int accepted
prefs[Keys.mutedTopics]         // List<String>, default empty
›

Make the key generic: PrefKey<T> holds the name and the default. The getter takes a PrefKey<T> and returns a T, so the key carries the type.

›

Storage only understands a few primitive types. Either give each key a way to read and write itself, or encode every value as JSON with the key’s type.

SolutionTry it yourself first
struct PrefKey<Value: Codable> {
    let name: String
    let defaultValue: Value
}

enum Keys {                                             // generic types can't have static stored properties
    static let darkMode = PrefKey(name: "darkMode", defaultValue: false)
    static let textSize = PrefKey(name: "textSize", defaultValue: 16)
    static let mutedTopics = PrefKey(name: "mutedTopics", defaultValue: [String]())
}

struct Preferences {
    let defaults: UserDefaults

    subscript<Value: Codable>(key: PrefKey<Value>) -> Value {
        get {
            guard let data = defaults.data(forKey: key.name),
                  let value = try? JSONDecoder().decode(Value.self, from: data) else { return key.defaultValue }
            return value
        }
        nonmutating set {
            defaults.set(try? JSONEncoder().encode(newValue), forKey: key.name)
        }
    }
}

let prefs = Preferences(defaults: .standard)
prefs[Keys.textSize] = 18           // prefs[Keys.textSize] = "big" doesn't compile
let dark: Bool = prefs[Keys.darkMode]
class PrefKey<T>(
    val name: String,
    val default: T,
    internal val read: SharedPreferences.(String, T) -> T,
    internal val write: SharedPreferences.Editor.(String, T) -> Unit,
)

object Keys {
    val darkMode = PrefKey("dark_mode", false, { k, d -> getBoolean(k, d) }, { k, v -> putBoolean(k, v) })
    val textSize = PrefKey("text_size", 16, { k, d -> getInt(k, d) }, { k, v -> putInt(k, v) })
    val mutedTopics = PrefKey<Set<String>>(
        "muted_topics", emptySet(),
        { k, d -> getStringSet(k, d)?.toSet() ?: d },     // copy: the returned set must not be modified
        { k, v -> putStringSet(k, v) },
    )
}

class Preferences(private val prefs: SharedPreferences) {
    operator fun <T> get(key: PrefKey<T>): T = key.read(prefs, key.name, key.default)
    operator fun <T> set(key: PrefKey<T>, value: T) = prefs.edit { key.write(this, key.name, value) }
}

val prefs = Preferences(context.getSharedPreferences("settings", Context.MODE_PRIVATE))
prefs[Keys.textSize] = 18           // prefs[Keys.textSize] = "big" doesn't compile
val dark: Boolean = prefs[Keys.darkMode]
public final class PrefKey<T> {
    final String name;
    final T defaultValue;
    final TypeReference<T> type;                         // keeps the full type for JSON

    public PrefKey(String name, T defaultValue, TypeReference<T> type) {
        this.name = name; this.defaultValue = defaultValue; this.type = type;
    }
}

public final class Keys {
    public static final PrefKey<Boolean> DARK_MODE = new PrefKey<>("darkMode", false, new TypeReference<>() {});
    public static final PrefKey<Integer> TEXT_SIZE = new PrefKey<>("textSize", 16, new TypeReference<>() {});
    public static final PrefKey<List<String>> MUTED_TOPICS = new PrefKey<>("mutedTopics", List.of(), new TypeReference<>() {});
}

public final class Preferences {
    private final Properties store;
    private final ObjectMapper json = new ObjectMapper();

    public Preferences(Properties store) { this.store = store; }

    public <T> T get(PrefKey<T> key) {
        String raw = store.getProperty(key.name);
        if (raw == null) return key.defaultValue;
        try { return json.readValue(raw, key.type); } catch (IOException e) { return key.defaultValue; }
    }

    public <T> void set(PrefKey<T> key, T value) {
        try { store.setProperty(key.name, json.writeValueAsString(value)); }
        catch (IOException e) { throw new UncheckedIOException(e); }
    }
}
interface PrefKey<T> {
  readonly name: string;
  readonly defaultValue: T;
}

const key = <T>(name: string, defaultValue: T): PrefKey<T> => ({ name, defaultValue });

const Keys = {
  darkMode: key("darkMode", false),
  textSize: key("textSize", 16),
  mutedTopics: key<string[]>("mutedTopics", []),
};

class Preferences {
  constructor(private readonly storage: Storage) {}            // e.g. localStorage

  get<T>(key: PrefKey<T>): T {
    const raw = this.storage.getItem(key.name);
    if (raw === null) return key.defaultValue;
    try {
      return JSON.parse(raw) as T;                              // trusted: only set() writes it
    } catch {
      return key.defaultValue;
    }
  }

  set<T>(key: PrefKey<T>, value: T): void {
    this.storage.setItem(key.name, JSON.stringify(value));
  }
}

const prefs = new Preferences(localStorage);
prefs.set(Keys.textSize, 18);         // prefs.set(Keys.textSize, "big") doesn't compile
const dark: boolean = prefs.get(Keys.darkMode);

The design in one sentence: the key carries the type. PrefKey<T> is a phantom-typed handle: the getter’s return type is inferred from the key, so callers never cast, and a wrong value type is a compile error.

Points worth defending:

  • One declaration per setting, so names and defaults can’t drift between screens.
  • Reading never crashes: corrupt or old-format values fall back to the default (and could be logged).
  • Swift and Java encode through JSON with the full type (Swift’s reified Value, Java’s TypeReference to survive erasure); the Kotlin version uses the native typed accessors instead, so values stay readable by other code that uses SharedPreferences directly.
  • Evolution: renaming a key loses old values, so a migration step reads the old name once. On Android, Jetpack DataStore’s Preferences.Key<T> is this same idea in the library, with a Flow for changes.

Deep dives

Java

Deep divePECS: producer extends, consumer superInterview favorite

List<? extends Number> is something you can read Numbers from (a producer), but not add to. List<? super Integer> is something you can add Integers to (a consumer), but reading only gives Object. Joshua Bloch’s mnemonic: Producer Extends, Consumer Super.

The JDK’s Collections.copy(List<? super T> dest, List<? extends T> src) is the textbook example: it lets you copy a List<Integer> into a List<Number>. Kotlin expresses the same idea with out and in, and lets you declare it once on the type (interface Source<out T>) instead of at every use.

Kotlin

Deep diveReified type parametersInterview favorite

On the JVM, generic type arguments are erased, so a normal function can’t do value is T or T::class. Kotlin’s inline functions can declare reified type parameters: because the body is copied into each call site, the compiler knows the actual type there and can use it.

That’s how inline fun <reified T> Gson.fromJson(json: String): T and inline fun <reified T : Activity> Context.start() work. The limits follow from the trick: only inline functions, the type must be known at the call site, and the function body is duplicated at every call.

Swift

Deep divesome vs anyInterview favorite

some Shape is an opaque type: one specific concrete type, chosen by the implementation and hidden from the caller. The compiler knows it, so calls are statically dispatched and specialized. any Shape is an existential: a box that can hold any conforming type, with dynamic dispatch and possibly a heap allocation for large values.

Since Swift 5.6 you write any explicitly to make that cost visible. Prefer some (and generics) by default, and use any when you really need different concrete types in one place, like [any Shape] holding circles and squares together.

TypeScript

Deep diveNo T at runtimeInterview favorite

TypeScript’s generics, like all its types, are erased: at runtime there is no T to check. function parse<T>(json: string): T { return JSON.parse(json) } compiles, but it’s a promise the compiler can’t keep: nothing verifies that the JSON is a T.

To check at runtime, pass something that exists at runtime: a constructor (create<T>(ctor: new () => T)), a type guard (isUser(x): x is User), or a schema (Zod’s z.infer<typeof schema> derives the type from the validator, so the two can’t drift apart).

SwiftKotlinJavaTypeScript
Runtime type argskept (reified, specializable)erased; inline + reifiederased; pass Class<T> / TypeReferenceall types erased; pass a schema

Swift

Decoding gets the full type: JSONDecoder().decode([User].self, from:). Across modules without @inlinable, generic code runs through witness tables instead of being specialized.

Kotlin

T::class.java drops type arguments even with reified: for List<User>, pass a TypeToken / typeOf<T>(), or use kotlinx.serialization, which knows the full type at compile time.

Java

List.class is all a Class can say: pass new TypeReference<List<User>>() {} (Jackson) or TypeToken (Gson). Superclass type arguments survive in the class file, which is why the anonymous subclass trick works. No overloads that differ only in type arguments.

TypeScript

fetchJson<User[]>(url) is a promise nothing checks. Pass a schema (Zod, Valibot) whose parse validates at runtime and whose type is inferred, so the type and the check can’t drift apart.

Check this part

  1. [String].self == [Int].self is…ArrayList<String>()::class == ArrayList<Int>()::class is…new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass() is…new Array<string>().constructor === new Array<number>().constructor is…

  2. Can Swift check value is [Int] while the app runs?Why doesn't x is List<String> compile in Kotlin?Why isn't x instanceof List<String> allowed in Java?How can you check at runtime that a value is a string[]?

  3. Why doesn't JSONDecoder().decode([User].self, from:) need a TypeToken-style trick?In inline fun <reified T>, what does T::class.java give Gson when T is List<User>?gson.fromJson(body, List.class) for a JSON array of users gives…fetchJson<User[]>(url) returns response.json(). What checks that the data really is a User[]?

Swift keeps generic type arguments at runtime; the JVM erases them, and TypeScript erases all types, so pass full types or schemas where runtime information is needed.

Key takeaways

  • A type parameter is a placeholder filled in at each use: write Page<T> once, use it for any item type, fully type-checked.
  • Constraints (T: Comparable) say what generic code may do with T.
  • Don’t step around generics with raw types, as!, unchecked casts or as: the failure moves away from the mistake.
  • Java and Kotlin erase type arguments at runtime, TypeScript erases all types, and Swift keeps them.
  • Variance: Kotlin out/in at declaration, Java PECS wildcards at use, Swift invariance with special-cased collections.
  • On the JVM, pass full types (TypeToken, TypeReference, typeOf<T>()), and in TypeScript a schema, wherever runtime type information is needed.
  • In Swift, prefer generics and some over any in hot code; know when specialization applies.
  • Let types carry meaning: phantom-typed keys and handles turn whole classes of runtime errors into compile errors.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.