Durante años, Java tuvo fama de verboso. Si querías una clase que solo guardara dos valores digamos, las coordenadas de un punto, tenías que escribir el constructor, dos getters, equals(), hashCode() y toString(). Cuarenta líneas para algo que en otros lenguajes cabe en una. Nadie lo disfrutaba.
Desde Java 16 eso ha cambiado bastante. Records, sealed classes, pattern matching y text blocks son features ya estables que reducen el ruido sin tocar lo que hace que Java sea Java: tipado estático fuerte, interoperabilidad garantizada y un compilador que se queja cuando algo no cuadra. Vamos feature por feature.
Records: la clase de datos en una línea
El record es lo más sencillo de explicar. Antes de Java 16, para representar un punto en 2D escribías algo así:
public final class Punto {
private final int x;
private final int y;
public Punto(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
@Override
public boolean equals(Object o) { ... }
@Override
public int hashCode() { ... }
@Override
public String toString() { ... }
}
Con records (JEP 395, GA en Java 16), todo eso queda en:
record Punto(int x, int y) {}
El compilador genera el constructor canónico, los accessors x() e y(), y las tres implementaciones de Object. Los campos son privados y final por defecto, y el propio record es implícitamente final: no puedes extenderlo.
Si necesitas validar los datos al construir, usas el constructor compacto:
record Punto(int x, int y) {
Punto {
if (x < 0 || y < 0) throw new IllegalArgumentException("Coordenadas negativas");
}
}
Fíjate que no repites los parámetros ni la asignación: el constructor compacto se ejecuta antes de que los campos se asignen, y la asignación ocurre sola al salir del bloque.
Los records pueden implementar interfaces, tener métodos de instancia adicionales y tener campos o métodos estáticos. Lo que no pueden es tener campos de instancia propios fuera de los componentes declarados, porque eso rompería la garantía de que los componentes definen completamente el estado del record.
El caso de uso más claro es cualquier clase que solo lleva datos: DTOs, value objects, respuestas de API, configuración inmutable. Si encuentras una clase con campos final, sin setters y con equals/hashCode implementados a mano, casi seguro que puede ser un record.
Sealed classes: cerrar la jerarquía
Las sealed classes (JEP 409, GA en Java 17) responden a una necesidad diferente. A veces quieres definir una jerarquía de tipos pero controlar exactamente qué clases pueden participar en ella.
sealed interface Forma permits Circulo, Rectangulo, Triangulo {}
Con esta declaración, solo Circulo, Rectangulo y Triangulo pueden implementar Forma. Cualquier otra clase que lo intente da error de compilación.
Cada permittee tiene que declarar qué hace con esa posibilidad:
final: nadie más puede extenderla.sealed: puede extenderse, pero con sus propias restricciones.non-sealed: vuelve a ser una clase normal, abierta a cualquier extensión.
record Circulo(double radio) implements Forma {}
record Rectangulo(double ancho, double alto) implements Forma {}
record Triangulo(double base, double altura) implements Forma {}
La combinación record + sealed es natural: los records son finales por definición, así que encajan directamente como permittees.
¿Para qué sirve cerrar la jerarquía? La utilidad principal no es la seguridad ni el encapsulamiento, sino algo más práctico: cuando el compilador sabe en tiempo de compilación cuáles son todas las subclases posibles, puede verificar que las cubres todas en un switch. Y ahí entra pattern matching.
Pattern matching para instanceof
El pattern matching para instanceof (JEP 394, GA en Java 16) resuelve un problema de ergonomía que todo desarrollador Java ha vivido cien veces. El código clásico:
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
El cast es redundante: si la condición es verdadera, ya sabes que es un String. Con pattern matching:
if (obj instanceof String s) {
System.out.println(s.length());
}
La variable s está disponible dentro del bloque donde la condición es verdadera. El compilador razona sobre el flujo de control, así que esto también funciona:
if (!(obj instanceof String s)) return;
// aquí s es accesible porque si no era String ya hemos salido
System.out.println(s.length());
Puedes añadir condiciones adicionales directamente:
if (obj instanceof String s && s.length() > 5) {
System.out.println("Cadena larga: " + s);
}
Pattern matching para switch: donde todo encaja
El pattern matching en switch (JEP 441, GA en Java 21) es la pieza que hace que todo lo anterior tenga sentido junto.
double area = switch (forma) {
case Circulo c -> Math.PI * c.radio() * c.radio();
case Rectangulo r -> r.ancho() * r.alto();
case Triangulo t -> (t.base() * t.altura()) / 2;
};
Tres cosas importantes aquí. Primera: el switch es una expresión, no una sentencia, así que devuelve un valor directamente. Segunda: no hace falta default porque el compilador sabe que Forma es sealed y tiene exactamente tres implementaciones: está todo cubierto. Si añades una cuarta al sealed interface y te olvidas de actualizar el switch, el compilador te lo dice en el acto. Tercera: en cada rama, la variable (c, r, t) ya tiene el tipo correcto, sin cast.
Puedes añadir guards para afinar los casos:
String descripcion = switch (forma) {
case Rectangulo r when r.ancho() == r.alto() -> "cuadrado de lado " + r.ancho();
case Rectangulo r -> "rectángulo " + r.ancho() + "x" + r.alto();
case Circulo c -> "círculo de radio " + c.radio();
case Triangulo t -> "triángulo";
};
El orden importa: los casos más específicos van antes. El compilador avisa si un caso nunca puede alcanzarse.
Record patterns: desestructurar records
Los record patterns (JEP 440, GA en Java 21) permiten desestructurar un record directamente en la condición de instanceof o en las ramas de un switch.
if (obj instanceof Punto(int x, int y)) {
System.out.println("x=" + x + ", y=" + y);
}
Sin tener que llamar a obj.x() ni a obj.y(). En un switch:
String info = switch (forma) {
case Circulo(double r) -> "radio=" + r;
case Rectangulo(double w, double h) -> w + "x" + h;
case Triangulo(double b, double h) -> "base=" + b;
};
Y los patrones se pueden anidar. Si tienes un record que contiene otro record:
record Segmento(Punto inicio, Punto fin) implements Forma {}
// en un switch o instanceof:
case Segmento(Punto(int x1, int y1), Punto(int x2, int y2)) ->
"longitud aprox: " + Math.hypot(x2 - x1, y2 - y1)
El guion bajo _ actúa de comodín cuando un componente no te interesa:
case Punto(int x, _) -> "x=" + x
Text blocks: strings multilínea legibles
Los text blocks (JEP 378, GA en Java 15) son el cambio más cosmético de todos, pero también el que más se agradece en el día a día. Antes, un JSON embebido en código quedaba así:
String json = "{n" +
" "nombre": "Ana",n" +
" "email": "[email protected]"n" +
"}";
Con text blocks:
String json = """
{
"nombre": "Ana",
"email": "[email protected]"
}
""";
Java elimina automáticamente el sangrado común de todas las líneas, así que la indentación del código no contamina el contenido del string. Las comillas dobles dentro no necesitan escape. Para queries SQL, HTML o cualquier string multilínea, la diferencia es notable.
Dos escapes nuevos que añaden control fino: al final de una línea une esa línea con la siguiente sin salto de línea; s marca un espacio explícito que el compilador no debe eliminar.
Juntando todo: el Result type en Java puro
La combinación de sealed interfaces, records y pattern matching permite construir patrones que antes requerían librerías externas o mucho código a mano. Un ejemplo concreto es el tipo Result para gestionar operaciones que pueden fallar sin lanzar excepciones:
sealed interface Resultado permits Exito, Fracaso {}
record Exito(T valor) implements Resultado {}
record Fracaso(String error) implements Resultado {}
Con estas tres líneas tienes un tipo que representa éxito o fracaso de forma explícita. Para consumirlo:
Resultado resultado = buscarUsuario(id);
String mensaje = switch (resultado) {
case Exito e -> "Usuario: " + e.valor();
case Fracaso f -> "Error: " + f.error();
};
El compilador verifica que cubres los dos casos. Si añades un tercer permittee al sealed interface, el switch deja de compilar hasta que lo añadas. No hay forma de olvidarse.
Este patrón evita excepciones para flujos de error esperados el usuario no existe, la validación falla, el servicio devuelve un 404 y hace que los tipos comuniquen claramente qué puede salir mal.
Cuándo usar cada cosa
Los records sustituyen cualquier clase que solo lleva datos inmutables. Si tienes setters, herencia o estado mutable, no es un record.
Las sealed classes tienen sentido cuando defines una jerarquía que quieres mantener cerrada y conocida en tiempo de compilación. No tiene sentido sellar una interfaz que cualquier consumidor externo de tu librería va a implementar.
El pattern matching para instanceof reemplaza el cast explícito cada vez que compruebes el tipo de un objeto. Es una mejora directa, sin trampa.
El pattern matching para switch brilla cuando tienes una sealed hierarchy y necesitas comportamiento diferente por tipo. Sin sealed classes, el compilador no puede verificar exhaustividad y necesitas un default.
Los text blocks van bien en cualquier string multilínea: SQL, HTML, JSON, XML. No los uses para strings de una sola línea, que quedan peor.
Para profundizar en el sistema de tipos de Java y las features que vienen después de Java 21, puedes ver JEP 401 y el futuro del sistema de tipos en Java. Y si quieres ver estos patrones aplicados a problemas concretos de algoritmia, tienes ejemplos prácticos de algoritmos modernos en Java con records y pattern matching.
Imagen: Pexels / neilstha firman
