Code of the Day

Structural typing and generics

Duck typing vs explicit declarations, and writing reusable code that keeps full static type safety through generics.

Data Types & Type Systems6 min read
By the end of this lesson you will be able to:
  • Describe the difference between structural and nominal type systems
  • Explain what generics are and why they matter for code reuse

Structural vs nominal typing

Two languages can both be statically typed yet disagree on what makes two types compatible.

Nominal typing says: type A is compatible with type B only if A explicitly declares that it extends or implements B. Java and C# work this way. The names matter.

says: type A is compatible with type B if A has at least the same fields and methods as B — regardless of what A is named or whether it mentions B at all. TypeScript and Go work this way.

// TypeScript (structural)
interface Printable { print(): void; }

class Logger {
  print() { console.log("log"); }  // never mentions Printable
}

function show(p: Printable) { p.print(); }
show(new Logger());  // works — Logger is structurally compatible with Printable

In Go this is called "implicit interface satisfaction." A type satisfies an interface the moment it implements all of the interface's methods, with zero ceremony. Structural typing reduces boilerplate and enables retroactive composition; nominal typing makes intent explicit and avoids accidental compatibility when two unrelated types happen to share a method name.

Generics

(also called parametric polymorphism) let you write a single function or data structure that works across many types without sacrificing static type-checking.

// Without generics: must duplicate for every type, or lose type safety with 'any'
function firstNumber(arr: number[]): number { return arr[0]; }
function firstString(arr: string[]): string { return arr[0]; }

// With generics: one function, still fully type-checked at each call site
function first<T>(arr: T[]): T { return arr[0]; }

const n = first([1, 2, 3]);    // T inferred as number
const s = first(["a", "b"]);   // T inferred as string

At the implementation level, languages handle generics differently. Java uses type erasure: the generic information exists only at compile time and is stripped from the bytecode, so all instantiations share a single compiled form. C++ uses monomorphisation: a separate, fully specialised copy of the code is compiled for each distinct type argument. Rust follows C++ here. Monomorphisation produces faster code (no virtual dispatch) but larger binaries.

The compiler treats a generic type parameter as "a type about which only what the constraints say is known." A constraint (or in Rust, type class in Haskell) restricts T to types that support a given set of operations — for instance, T: Ord means T supports comparison.

The type system is, in a real sense, a formal proof system embedded in the compiler. When a Rust program compiles, you have a machine-checked proof that the code does not contain data races or use-after-free errors. The annotations you write are the premises; the type-checker is the proof assistant.

Where to go next

The type system interacts directly with how values are stored and passed through the machine. The Computer Architecture track covers how registers, cache, and memory hierarchy affect the cost of the operations the type system permits. For a practical application of type-system thinking, see the Rust track, which has one of the most expressive type systems in mainstream use.

Knowledge check

  1. 1.
    Which of the following are true of structural typing?
  2. 2.
    In Java, a generic List<String> and List<Integer> compile to the same bytecode. This is called:
Finished reading? Mark it complete to track your progress.

On this page