DEV Community

Daniel Keya
Daniel Keya

Posted on

If It Quacks Like a Duck: Duck Typing in Java

The duck test

"If it walks like a duck and quacks like a duck, then it must be a duck."

In Python, Ruby, or JavaScript, this is a real programming principle called duck typing. You don't care what type an object is, only what it can do:

class Duck:
    def quack(self): print("Quack!")

class Robot:
    def quack(self): print("Beep-quack!")

def make_it_quack(thing):
    thing.quack()   # no type check, just trust

make_it_quack(Duck())
make_it_quack(Robot())
Enter fullscreen mode Exit fullscreen mode

Java is statically typed, so the compiler wants to know that thing is something that has quack() before the program runs. There is no built-in duck typing.

But that doesn't mean we can't get the same flexibility. Let's look at five ways to play the duck game in Java, from most idiomatic to most hacky.

1. The Java way: interfaces (structural intent, nominal typing)

Java's answer to duck typing is the interface. Instead of "has a quack() method," Java says "declares that it can quack."

interface Quacker {
    void quack();
}

class Duck implements Quacker {
    public void quack() { System.out.println("Quack!"); }
}

class Robot implements Quacker {
    public void quack() { System.out.println("Beep-quack!"); }
}

public class Main {
    static void makeItQuack(Quacker q) {
        q.quack();
    }

    public static void main(String[] args) {
        makeItQuack(new Duck());
        makeItQuack(new Robot());
    }
}
Enter fullscreen mode Exit fullscreen mode

The catch: the type has to opt in. If Robot comes from a library you can't modify, you can't make it implement Quacker. Java cares about the name tag, not the behavior.

2. Lambdas and method references: duck typing for single behaviors

Since Java 8, any interface with a single abstract method is a functional interface, and lambdas can satisfy it without any class declaring anything:

class ThirdPartyRobot {
    public void beepQuack() { System.out.println("Beep-quack!"); }
}

public class Main {
    static void makeItQuack(Quacker q) { q.quack(); }

    public static void main(String[] args) {
        ThirdPartyRobot robot = new ThirdPartyRobot();

        // Adapt it: if it can do the thing, it IS the thing
        makeItQuack(robot::beepQuack);
        makeItQuack(() -> System.out.println("Lambda quack!"));
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the closest Java gets to duck typing in everyday code. You didn't change ThirdPartyRobot, and the method name didn't even have to match. You only needed the right shape (void with no arguments).

The standard library leans on this heavily: Runnable, Supplier<T>, Comparator<T>, and Function<T,R> are all "if it fits the shape, it works."

3. Generics with bounded types

When you need to work with any type that can do X, use bounded generics:

static <T extends Quacker> void chorus(List<T> animals) {
    animals.forEach(Quacker::quack);
}
Enter fullscreen mode Exit fullscreen mode

This is still nominal ("must extend Quacker"), but it lets you write code that works with any quacker while keeping full type safety and avoiding casts.

4. Reflection: actual duck typing (at a cost)

If you really want "just call quack() if it exists," reflection can do it:

import java.lang.reflect.Method;

public class DuckTyper {
    static void makeItQuack(Object obj) {
        try {
            Method m = obj.getClass().getMethod("quack");
            m.invoke(obj);
        } catch (NoSuchMethodException e) {
            System.out.println(obj.getClass().getSimpleName() + " is NOT a duck.");
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) {
        makeItQuack(new Duck());
        makeItQuack("I'm a String");   // not a duck
    }
}
Enter fullscreen mode Exit fullscreen mode

This is genuine duck typing: no shared interface, no declaration. But you pay for it:

  • No compile-time safety. Typos like "qaack" blow up at runtime.
  • Slower than a direct method call.
  • Harder to refactor. IDEs can't track string-based method names.
  • Breaks with encapsulation (modules, private access, etc.).

Use this for frameworks, plugins, and serialization, not for business logic.

5. Dynamic proxies: wrap anything in a duck costume

Java's java.lang.reflect.Proxy can generate an implementation of an interface at runtime and forward calls to any object that has matching methods:

import java.lang.reflect.*;

public class DuckProxy {
    @SuppressWarnings("unchecked")
    static <T> T asDuck(Object target, Class<T> iface) {
        return (T) Proxy.newProxyInstance(
            iface.getClassLoader(),
            new Class<?>[]{ iface },
            (proxy, method, args) -> {
                Method m = target.getClass()
                    .getMethod(method.getName(), method.getParameterTypes());
                return m.invoke(target, args);
            }
        );
    }

    public static void main(String[] args) {
        class Mallard { public void quack() { System.out.println("Quack from a mallard!"); } }

        Quacker q = asDuck(new Mallard(), Quacker.class);
        q.quack();   // works, though Mallard never implemented Quacker
    }
}
Enter fullscreen mode Exit fullscreen mode

Now Mallard never declared implements Quacker, yet you can use it as one. The compiler is happy at the call site because q really is a Quacker, and the duck check is deferred to runtime.

So which should you use?

Approach Safety Flexibility Use when
Interfaces High Low You own the types (default choice)
Lambdas / method refs High Medium Single-behavior adapters
Bounded generics High Medium Reusable, type-safe utilities
Reflection Low High Frameworks, plugins, tooling
Dynamic proxies Medium High Adapters over types you can't change

Rule of thumb: start with interfaces and lambdas. Reach for reflection or proxies only when you truly can't modify the types involved.

Is Java's way worse?

Not necessarily. Duck typing trades compile-time guarantees for flexibility. The Java approach trades a little ceremony for:

  • Errors caught before you ship
  • Better IDE autocomplete and refactoring
  • Self-documenting contracts (implements Quacker tells you something)

As the saying goes, a static type system is a duck test you take at compile time instead of at 3 a.m. in production.

Wrapping up

Java doesn't quack like a dynamic language, but with functional interfaces, generics, and a bit of reflection, it can walk like one when it needs to. Knowing which tool to pick is what separates a Java developer who fights the language from one who bends it.

What's your favorite way to get duck-like flexibility in Java? Let me know in the comments. 🦆

Top comments (0)