wandres.dev
SINTAXIS ESENCIAL · todo es una expresión

if y when como expresiones

La diferencia real entre sentencia y expresión y por qué Kotlin no necesita operador ternario, when con sujeto y sin sujeto, la exhaustividad como requisito de tipado y cómo se calcula el valor de retorno de cada rama.

⏱ 15 min

Kotlin no tiene operador ternario y eso desconcierta a todo el que llega de C o de Java. No lo tiene porque no lo necesita: if ya es una expresión, así que el ternario sería una segunda sintaxis para lo mismo. Detrás de esa ausencia hay una decisión de diseño más profunda —casi todo el control de flujo produce un valor— cuya consecuencia práctica es que puedes escribir mucho menos estado mutable temporal.

🎯 Al terminar esta lección sabrás
  • Distinguir sentencia de expresión y ver la diferencia en código real.
  • Usar if como expresión, incluyendo la forma con bloques.
  • Dominar when con sujeto y sin sujeto, con rangos, tipos y guardas.
  • Entender la exhaustividad y cómo se calcula el tipo del resultado.

Sentencia y expresión

Una sentencia se ejecuta por su efecto; una expresión se evalúa y produce un valor que puede colocarse allí donde se espera un valor. En Java if es sentencia, y de ahí nace un patrón que todo el mundo ha escrito mil veces: declarar una variable vacía, asignarla desde las ramas y confiar en no olvidar ninguna.

// El patrón heredado, con estado mutable innecesario
var etiqueta: String
if (n > 0) etiqueta = "positivo" else etiqueta = "no positivo"

// El mismo cálculo como expresión
val etiqueta2 = if (n > 0) "positivo" else "no positivo"

La segunda versión gana tres cosas a la vez. La variable pasa a ser val, con lo que nadie puede reasignarla después. Desaparece el estado intermedio no inicializado. Y el compilador exige el else: un if usado como expresión sin rama alternativa es un error, porque no habría valor que producir en un caso.

Con bloques funciona igual; el valor de un bloque es el de su última expresión:

val tarifa = if (esSocio) {
    val base = calcularBase()
    base * 0.8            // este es el valor del bloque
} else {
    calcularBase()
}

Esa regla, la de la última expresión, es la misma que gobierna los lambdas y los cuerpos de expresión. No es una excepción de if: es un principio uniforme del lenguaje.

when con sujeto y sin sujeto

when es la generalización. Con sujeto compara ese valor contra cada rama; sin sujeto evalúa condiciones booleanas en orden y toma la primera cierta, sustituyendo con elegancia a cualquier cadena de else if.

// Con sujeto: igualdad, pertenencia a rango, comprobación de tipo
val categoria = when (codigo) {
    0 -> "vacio"
    in 1..9 -> "digito"
    in 10..99, in 100..999 -> "corto"
    is Int -> "entero grande"
    else -> "desconocido"
}

// Sin sujeto: cadena de condiciones arbitrarias
val nivel = when {
    temperatura > 40 -> "critico"
    temperatura > 30 -> "alto"
    hayAlerta && esNoche -> "vigilar"
    else -> "normal"
}

Las ramas admiten varios valores separados por coma, pertenencia con in y !in, comprobación de tipo con is y !is —que además activa el smart cast dentro de la rama— y cualquier expresión arbitraria cuando hay sujeto.

El sujeto puede capturarse en el propio when, lo cual acota su alcance a la construcción y evita ensuciar el ámbito exterior:

when (val respuesta = servicio.consultar()) {
    is Exito -> procesar(respuesta.datos)   // smart cast a Exito
    is Fallo -> registrar(respuesta.causa)
}

A eso se suman las guardas, disponibles desde la línea 2.1 del lenguaje, que permiten añadir una condición extra a una rama con sujeto sin renunciar al smart cast:

val accion = when (evento) {
    is Pulsacion if evento.repetida -> "doble"
    is Pulsacion -> "simple"
    else -> "ninguna"
}
flowchart TD
A[Necesito elegir entre varios caminos] --> B[Dos ramas y condicion booleana]
B -->|si| C[if como expresion]
B -->|no| D[Varias ramas]
D --> E[Comparo un mismo valor]
E -->|si| F[when con sujeto]
E -->|no| G[when sin sujeto]
style C fill:#a6e3a1,color:#11111b
style F fill:#89b4fa,color:#11111b
style G fill:#89b4fa,color:#11111b

Exhaustividad y valor de retorno

Aquí está el matiz que separa a quien usa when de quien lo entiende. Un when usado como expresión debe ser exhaustivo: o tiene else, o cubre demostrablemente todos los casos posibles del sujeto. El compilador sabe demostrarlo cuando el sujeto es un enum, una jerarquía sealed o un Boolean.

sealed interface Estado
data object Cargando : Estado
data class Listo(val n: Int) : Estado
data class Error(val causa: String) : Estado

fun describir(e: Estado): String = when (e) {
    Cargando -> "cargando"
    is Listo -> "listo con ${e.n}"
    is Error -> "error: ${e.causa}"
}   // sin else: el compilador comprueba que están todos

Esa propiedad es la que convierte a sealed más when en la herramienta de refactor más valiosa del lenguaje: al añadir un nuevo subtipo, todos los when exhaustivos del proyecto dejan de compilar y te señalan exactamente dónde falta lógica. Añadir un else por comodidad destruye esa red de seguridad, porque el caso nuevo caería silenciosamente en él.

ℹ️
También como sentencia

Desde Kotlin 1.7 un when con sujeto enum o sealed usado como sentencia y sin cubrir todos los casos es un error, no solo un aviso. La exhaustividad dejó de ser una exigencia del tipado para convertirse en una regla del lenguaje.

El tipo del resultado es el supertipo común mínimo de los tipos de todas las ramas. Si todas devuelven String, el resultado es String; si una devuelve Int y otra String, obtendrás un supertipo incómodo que casi siempre indica un error de diseño. Y las ramas que no producen valor —las que lanzan o retornan— tienen tipo Nothing y, por ser subtipo de todo, no ensucian el cálculo:

val puerto: Int = when (perfil) {
    "dev" -> 8080
    "prod" -> 443
    else -> error("perfil desconocido")   // Nothing: no altera el tipo
}

En la JVM, un when con sujeto entero o de cadena y ramas constantes se compila a una instrucción de salto por tabla, igual que un switch; en el resto de casos degrada a comparaciones encadenadas. La elegancia sintáctica no cuesta rendimiento en el caso habitual.

Que todo sea expresión no es azúcar, es una restricción sobre el estado

La diferencia entre sentencia y expresión parece cosmética hasta que se mira lo que cada una obliga a escribir. Un lenguaje de sentencias fuerza a comunicar resultados a través de variables mutables: declaras el hueco antes de saber qué va dentro, lo rellenas desde varias ramas y confías en tu disciplina para que ninguna quede sin cubrir. Un lenguaje de expresiones invierte la dirección: la construcción de control devuelve el valor y tú lo enlazas a un nombre inmutable de una sola vez. Lo importante es lo que el compilador puede exigir a cambio. Como el if debe producir un valor, el else deja de ser opcional. Como el when debe producir un valor, la exhaustividad se vuelve verificable, y con jerarquías sealed se convierte en una comprobación de cobertura total en tiempo de compilación. Ninguna de esas garantías existe cuando las ramas se limitan a producir efectos. Por eso Kotlin no añadió el ternario: no le faltaba una sintaxis para elegir valores, le sobraba la categoría de las construcciones que no valen nada.

⚔️ Convierte sentencias en expresiones
  1. Busca en tu código un var inicializado desde varias ramas de un if y reescríbelo como un val con if expresión.
  2. Escribe un if expresión sin else y lee el error; después úsalo como sentencia y comprueba que ahí sí se permite.
  3. Define una interfaz sealed con tres implementaciones y un when exhaustivo sin else. Añade una cuarta y observa qué rompe.
  4. Reescribe una cadena de else if como when sin sujeto y compara la legibilidad.
  5. Investiga: escribe un when donde una rama llame a error y comprueba con el tipo inferido que la rama Nothing no altera el resultado.