Pattern-matching across different languages


Pattern matching is a major feature in software development. While pattern matching applies in several locations, its current usage is limited to switch case
blocks. I want to compare the power of pattern matching across a couple of programming languages I'm familiar with in this post.
I assume that every reader is familiar with the switch case
syntax inherited from C. In short:
- The
switch
clause references a value-returning statement - Each
case
clause sets another statement; if the value matches the statement, it executes the related block case
clauses are evaluated in order. The first clause that matches gets its block executed- In C,
case
clauses are fall-through; you need to explicitlybreak
to escape theswitch
, otherwise, the nextcase
is evaluated
Java's pattern matching
I'll start with Java, as it was the first programming language I used in a professional context.
Java's pattern matching has evolved a lot across its versions. Here's the "official" sample for version 23, slightly amended. It defines several Shape
implementations and a static
method to evaluate its perimeter. It's definitely not a good example of Object-Oriented Programming.
interface Shape { }
record Rectangle(double length, double width) implements Shape { }
record Circle(double radius) implements Shape { }
public class Main {
static double getPerimeter(Shape s) throws IllegalArgumentException {
return switch (s) { //1
case Rectangle r when r.length() == r.width() -> { //2
System.out.println("Square detected");
yield 4 * r.length();
}
case Rectangle r -> //3
2 * r.length() + 2 * r.width();
case Circle c -> //4
2 * c.radius() * Math.PI;
default -> //5
throw new IllegalArgumentException("Unrecognized shape");
};
}
}
- Reference the
Shape
parameter ass
- Evaluate whether
s
is aRectangle
and whether theRectangle
is a square - Evaluate whether
s
is aRectangle
- Evaluate whether
s
is aCircle
- If none of the previous clauses match, default to throwing an exception
This Java version will be our baseline.
Characteristics of the new switch
syntax
The new syntax has some advantages over the legacy non-arrow switch
inherited from C.
C-style case
clauses are fall-through. Once the runtime has executed a case
block, it runs the next case
block. To avoid it, you need to set a break
explicitly. In the not-so-good old days, some developers leveraged this feature to avoid code duplication. However, experience has shown that it was too much of a cause of potential bugs: modern coding practices tend to avoid it. Yet, it's too easy to forget a break
. With the new arrow syntax, the runtime only evaluates the corresponding case
block.
C-style case
clauses only evaluate simple values. The community celebrated Java 7 as a huge boon when it allowed String values. Java 21 went even further: coupled with the arrow syntax, it allowed for switching on types. In the above example, we check the exact Shape
type.
Additionally, if switching on classes and if the class hierarchy is sealed
, the compiler can automatically detect missing cases. Finally, version 24 added the additional optional when
filter.
On the flip side, in the old C syntax, the runtime jumps directly to the correct case
clause. The new arrow syntax evaluates them sequentially, exactly like if with if else
statements.
In the next sections, we will port the code to other languages.
Scala's pattern matching
Scala's pattern matching has been second to none since its inception. Kotlin drew a lot of inspiration from it.
trait Shape
case class Rectangle(length: Double, width: Double) extends Shape
case class Circle(radius: Double) extends Shape
def getPerimeter(s: Shape) = {
s match { //1
case Rectangle(length, width) if length == width => //2-3
println("Square detected")
4 * length
case Rectangle(length, width) => 2 * length + 2 * width //3-4
case Circle(radius) => 2 * radius * Math.PI //3-5
case _ => throw new IllegalArgumentException("Unrecognized shape") //6
}
}
- Use the
s
reference directly - Evaluate whether
s
is aRectangle
and whether theRectangle
is a square - Scala additionally matches the class attributes
- Evaluate whether
s
is aRectangle
- Evaluate whether
s
is aCircle
- If none of the previous clauses match, default to throwing an exception
Kotlin's pattern matching
Let's translate Java's code to Kotlin. For that, we must activate the experimental Xwhen-guards
compilation feature described in KEEP-371.
interface Shape
data class Rectangle(val length: Double, val width: Double): Shape
data class Circle(val radius: Double): Shape
fun getPerimeter(s: Shape) = when (s) { //1
is Rectangle if s.length == s.width -> { //2
println("Square detected")
4 * s.length
}
is Rectangle -> 2 * s.length + 2 * s.width //3
is Circle -> 2 * s.radius * Math.PI //4
else -> throw IllegalArgumentException("Unknown shape") //5
}
}
- Reference the
Shape
parameter ass
- Evaluate whether
s
is aRectangle
and whether theRectangle
'swidth
is equal to itsheight
- Evaluate whether
s
is aRectangle
- Evaluate whether
s
is aCircle
- If none of the previous clauses match, default to throwing an exception
Kotlin's pattern matching greatly resembles Java's, with slight syntax changes, e.g., if
replaces when
.
The comparative evolution of Kotlin vs. Java in pattern matching is quite enlightening. Java lagged behind the legacy C syntax, while Kotlin could already match on concrete types from the beginning. In 2020, Java 14 reduced the gap with the arrow operator; one year later, Java 16 closed the gap completely with the ability to match on concrete types. Finally, Java 23 added the when
clause.
The experimental nature of Kotlin's if
means Java has overtaken Java–at least in this area! It's rare enough to be noted.
Python's pattern matching
Before, Python didn't offer anything similar to the switch
statement of the above JVM languages. From version 3.10, it does offer the same capability in an elegant way:
class Shape:
pass
class Rectangle(Shape):
def __init__(self, length: float, width: float):
self.length = length
self.width = width
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def get_perimeter(s: Shape) -> float:
match s:
case Rectangle(length=l, width=w) if l == w:
print("Square detected")
return 4 * l
case Rectangle(length=l, width=w):
return 2 * l + 2 * w
case Circle(radius=r):
return 2 * pi * r
case _:
raise ValueError("Unknown shape")
The runtime evaluates the case
clauses sequentially, as in the JVM languages.
Rust's pattern matching
Rust's approach to memory management doesn't play well with checking types. In short, Rust offers two base concepts:
- Structures are structured data placeholders; their memory size is known at compile time
- Traits are contracts, akin to interfaces
- You can provide an implementation of a trait for a structure
- Referencing a variable by its trait has consequences. Since the reference can point to structures of different sizes, the compiler can't know its size at compile-time and must place the variable on the heap instead of the stack.
I tried to port the original code to Rust one-to-one for educational purposes. The original Java doesn't take advantage of polymorphism; it is not great. In Rust, it's even uglier. It's interesting to realize that while Rust isn't an Object-Oriented programming language, it leads you to use polymorphism.
Warning: I don't recommend the following code and will deny any association with it!
trait Shape: Any {
fn as_any(&self) -> &dyn Any; //1
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn as_any(&self) -> &dyn Any { //2
self
}
}
impl Shape for Rectangle {
fn as_any(&self) -> &dyn Any { //2
self
}
}
fn get_perimeter(s: Box<dyn Shape>) -> f64 {
match s.as_any() { //3
any if any.is::<Rectangle>() => { //4
let rectangle = any.downcast_ref::<Rectangle>().unwrap(); //5
if rectangle.width == rectangle.height {
println!("Square matched");
4.0 * rectangle.width
} else {
2.0 * (rectangle.width + rectangle.height)
}
}
any if any.is::<Circle>() => { //4
let circle = any.downcast_ref::<Circle>().unwrap(); //5
2.0 * std::f64::consts::PI * circle.radius
}
_ => panic!()
}
}
- Rust's type system doesn't offer a way to get the type of a variable. We must create a dedicated function for that.
- Implement the function for structures
- Match on the underlying structure type
- Check the type
- Downcast to the underlying structure to use its fields
This artificial code port above misrepresents Rust's pattern-matching abilities. It applies in many places.
Conclusion
Among all languages described in the post, Scala was the first to provide pattern-matching in switch
clauses. For many years, it was the Grail that others tried to catch up with; Kotlin and Java have finally reached this stage.
Outside of the JVM, Python and Rust feature powerful pattern-matching capabilities.
With destructuring, pattern matching is a huge help to developers who want to write readable and maintainable code.
The complete source code for this post can be found on GitHub:
To go further:
- Pattern matching in Java 23
- Pattern matching in Kotlin
- Pattern matching in Scala
- Match statement in Python
- Pattern matching in Rust
Originally published at A Java Geek on July 20th, 2025
Subscribe to my newsletter
Read articles from Nicolas Fränkel directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Nicolas Fränkel
Nicolas Fränkel
Technologist focusing on cloud-native technologies, DevOps, CI/CD pipelines, and system observability. His focus revolves around creating technical content, delivering talks, and engaging with developer communities to promote the adoption of modern software practices. With a strong background in software, he has worked extensively with the JVM, applying his expertise across various industries. In addition to his technical work, he is the author of several books and regularly shares insights through his blog and open-source contributions.