Lección 4 de 8. Si has programado en Java, el NullPointerException es un viejo conocido: el programa compila, y al ejecutarse se detiene porque algo valía null. Kotlin mueve ese problema al compilador. En esta lección verás cómo funcionan los tipos que aceptan null y los operadores ?., ?: y !!, y qué pasa cuando tu código de Kotlin llama a código de Java, que no tiene estas garantías.
El error de mil millones de dólares
Tony Hoare, el científico que inventó la referencia nula en 1965, la llamó años después su «error de mil millones de dólares», por todos los fallos que ha causado. En Java, cualquier variable de un objeto puede valer null, y si llamas a un método sobre ella el programa se detiene con un NullPointerException. El compilador no dice nada: el fallo aparece al ejecutar, muchas veces en producción y lejos del lugar donde se coló el null.
Kotlin ataca el problema desde el sistema de tipos. Cada tipo existe en dos versiones: String, que nunca es null, y String?, que puede serlo. El compilador lleva la cuenta y no te deja usar un String? como si fuera un String. A esto se le llama null safety, y es una de las razones por las que Google eligió Kotlin para Android.
String y String?
Una variable de tipo String no admite null. Si lo intentas, el programa ni siquiera compila:
fun main() {
var nombre: String = "Ana"
nombre = null
println(nombre)
}kotlincasignar_null.kt:3:14: error: null cannot be a value of a non-null type 'String'.
nombre = null
^^^^El mensaje es claro: null no puede ser el valor de un tipo que no acepta null (non-null type). Si lo compilas tú verás además un segundo error en el println, una ambigüedad entre sus versiones: es un efecto en cadena del primero y desaparece al corregirlo. Pasa a menudo: arregla siempre el primer error y vuelve a compilar.
Para guardar un valor que puede faltar, se declara el tipo con ?. Ahora sí se puede asignar null, y println lo escribe tal cual:
fun main() {
var apodo: String? = "Anita"
println(apodo)
apodo = null
println(apodo)
}salidaAnita null
Lo interesante viene al usarla. Con un String? no puedes llamar directamente a sus propiedades ni a sus métodos, porque podrían no existir:
El error que te ahorra: llamar a un método sobre algo que puede ser null
fun main() {
val apodo: String? = null
println(apodo.length)
println(apodo.uppercase())
}kotlincsin_comprobar.kt:3:18: error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.
println(apodo.length)
^
sin_comprobar.kt:4:18: error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.
println(apodo.uppercase())
^En Java este código compilaría y fallaría al ejecutarse con un NullPointerException. kotlinc lo detiene antes: las dos líneas usan un receptor que acepta null (nullable receiver) y el mensaje te dice las dos salidas posibles, la llamada segura ?. o la aserción !!.. Las vemos enseguida.
Lo mismo pasa con los parámetros y los valores de retorno de las funciones: el tipo dice si pueden ser null, y quien llama lo sabe sin leer la documentación. Por ejemplo, toIntOrNull() devuelve Int? porque el texto puede no ser un número:
fun main() {
val textos = listOf("42", "siete", "-3")
for (t in textos) {
val n: Int? = t.toIntOrNull()
println("$t -> $n")
}
}salida42 -> 42 siete -> null -3 -> -3
La llamada segura: ?.
El operador ?. llama al método solo si el valor no es null; si lo es, todo el resultado es null, sin fallar. Por eso apodo?.length es de tipo Int?:
fun main() {
val conApodo: String? = "Anita"
val sinApodo: String? = null
println(conApodo?.length)
println(sinApodo?.length)
println(sinApodo?.uppercase())
}salida5 null null
Las llamadas seguras se pueden encadenar. Si cualquier eslabón es null, la cadena entera da null. Aquí el cliente puede no tener dirección, y la dirección puede no tener código postal:
class Direccion(val calle: String, val codigoPostal: String?)
class Cliente(val nombre: String, val direccion: Direccion?)
fun main() {
val clientes = listOf(
Cliente("Ana", Direccion("Reforma 222", "06600")),
Cliente("Luis", Direccion("Juárez 15", null)),
Cliente("Eva", null),
)
for (c in clientes) {
println("${c.nombre}: ${c.direccion?.codigoPostal?.length}")
}
}salidaAna: 5 Luis: null Eva: null
El operador Elvis: ?:
Muchas veces, si algo es null, quieres usar otro valor en su lugar. Para eso está ?:, al que llaman operador Elvis porque, girado, parece el copete de Elvis Presley. Si lo de la izquierda no es null, el resultado es eso; si es null, es lo de la derecha:
fun main() {
val apodo: String? = null
val saludo = "Hola, ${apodo ?: "desconocido"}"
println(saludo)
val texto = "doce"
val cantidad: Int = texto.toIntOrNull() ?: 0
println("Cantidad: $cantidad")
val largo: Int = apodo?.length ?: 0
println("Largo: $largo")
}salidaHola, desconocido Cantidad: 0 Largo: 0
Fíjate en los tipos: texto.toIntOrNull() ?: 0 ya es un Int sin interrogación, porque en los dos casos hay un número. Esa es la manera habitual de salir del mundo de los null: dar un valor por omisión en cuanto se puede.
A la derecha del Elvis también puede ir un return o un throw. Es el patrón de la salida temprana: si falta el dato, la función termina ahí, y el resto del código trabaja con un valor que ya no es null:
fun precioConIva(texto: String): String {
val precio = texto.toDoubleOrNull() ?: return "«$texto» no es un precio"
val total = precio * 1.16
return "Total con IVA: ${"%.2f".format(total)}"
}
fun edadValida(edad: Int?): Int {
val e = edad ?: throw IllegalArgumentException("Falta la edad")
return e
}
fun main() {
println(precioConIva("100"))
println(precioConIva("cien"))
println(edadValida(30))
println(edadValida(null))
}salida con errorTotal con IVA: 116.00
«cien» no es un precio
30
Exception in thread "main" java.lang.IllegalArgumentException: Falta la edad
at Elvis_returnKt.edadValida(elvis_return.kt:8)
at Elvis_returnKt.main(elvis_return.kt:16)Las tres primeras llamadas funcionan; la cuarta lanza la excepción que pusimos a propósito. Después de la línea del Elvis, precio es Double y e es Int, sin interrogación.
Smart cast: comprobar una vez y usar sin miedo
La comprobación de toda la vida, if (x != null), también sirve. Dentro del if, el compilador sabe que x ya no es null y lo trata como String, sin que tengas que convertirlo. A eso se le llama smart cast (conversión inteligente):
fun describir(texto: String?) {
if (texto != null) {
println("«$texto» tiene ${texto.length} letras")
} else {
println("No hay texto")
}
}
fun primeraLetra(texto: String?): Char {
if (texto == null || texto.isEmpty()) return '?'
return texto[0]
}
fun main() {
describir("Kotlin")
describir(null)
println(primeraLetra("mango"))
println(primeraLetra(null))
}salida«Kotlin» tiene 6 letras No hay texto m ?
En primeraLetra, después del return el compilador sabe que texto no es null ni está vacío, y deja usar texto[0]. Ojo: también en texto.isEmpty(), dentro de la misma condición, porque el || solo evalúa la segunda parte si la primera es falsa.
El error que te ahorra: el smart cast que no funciona con var de una clase
class Pedido {
var nota: String? = "Sin cebolla"
}
fun main() {
val p = Pedido()
if (p.nota != null) {
println(p.nota.length)
}
}kotlincsmart_cast_var.kt:8:17: error: smart cast to 'String' is impossible, because 'nota' is a mutable property that could be mutated concurrently.
println(p.nota.length)
^^^^^^Parece igual que antes, pero no lo es: nota es una propiedad var de una clase, y entre la comprobación y el uso otro hilo podría ponerla en null. Kotlin no se arriesga. La solución es copiarla a una variable local, que nadie más puede tocar: val nota = p.nota y luego if (nota != null), o directamente p.nota?.length.
La versión que compila, copiando a una variable local, o con ?.let, que hace lo mismo en una línea:
class Pedido {
var nota: String? = "Sin cebolla"
}
fun main() {
val p = Pedido()
val nota = p.nota
if (nota != null) {
println(nota.length)
}
p.nota?.let { println("Nota: $it ($it.length)") }
p.nota?.let { texto -> println("Nota: $texto (${texto.length} letras)") }
}salida11 Nota: Sin cebolla (Sin cebolla.length) Nota: Sin cebolla (11 letras)
Mira la segunda línea con let: $it.length dentro de una cadena no llama a length, solo pone el valor de it seguido del texto «.length». Para meter una expresión hacen falta las llaves, ${it.length}, como en la tercera.
let: hacer algo solo si no es null
?.let { ... } ejecuta el bloque únicamente si el valor no es null, y dentro el valor se llama it (o el nombre que le pongas), ya sin interrogación. Es la forma idiomática de decir «si hay dato, haz esto»:
fun buscarCorreo(nombre: String): String? =
if (nombre == "ana") "ana@example.com" else null
fun main() {
for (nombre in listOf("ana", "luis")) {
buscarCorreo(nombre)?.let { correo ->
println("Enviando aviso a $correo")
}
val dominio = buscarCorreo(nombre)?.let { it.substringAfter("@") } ?: "sin correo"
println("$nombre: $dominio")
}
}salidaEnviando aviso a ana@example.com ana: example.com luis: sin correo
Volveremos a let y a sus primas apply, also, run y with en la lección 6.
El operador !!: «confía en mí»
!! convierte un String? en String a la fuerza. Si el valor no es null, todo bien. Si lo es, lanza un NullPointerException en esa misma línea. Es decir, te devuelve el comportamiento de Java:
fun main() {
val hay: String? = "Kotlin"
println(hay!!.length)
val falta: String? = null
println("Antes del fallo")
println(falta!!.length)
println("Esto ya no se escribe")
}salida con error6
Antes del fallo
Exception in thread "main" java.lang.NullPointerException
at Doble_exclamacionKt.main(doble_exclamacion.kt:7)Fíjate en que este NullPointerException no trae mensaje: lo lanza Kotlin, no la JVM. La traza señala la línea exacta del !!, cosa que ya es mejor que en Java, donde el null pudo colarse mucho antes. Aun así, cada !! es una promesa que el compilador no puede comprobar. Úsalo solo cuando sepas algo que el compilador no sabe, y si te encuentras escribiendo muchos, probablemente un ?: o un let lo resolvería mejor.
Otras herramientas del día a día
Conversión segura con as?
as convierte un valor a otro tipo y lanza ClassCastException si no se puede. as? devuelve null en ese caso, y se combina muy bien con el Elvis:
fun main() {
val valores: List<Any> = listOf("hola", 42, 3.5)
for (v in valores) {
val texto: String? = v as? String
println("$v -> ${texto?.uppercase() ?: "no es texto"}")
}
}salidahola -> HOLA 42 -> no es texto 3.5 -> no es texto
Colecciones con null
Ojo con dónde va la interrogación: List<String?> es una lista que siempre existe pero cuyos elementos pueden ser null; List<String>? es una lista que puede no existir, pero si existe no tiene ningún null. La biblioteca estándar trae funciones para limpiar los null:
fun main() {
val respuestas: List<String?> = listOf("sí", null, "no", null, "sí")
println(respuestas.filterNotNull())
println(respuestas.count { it == null })
val edades = listOf("31", "", "veinte", "45")
println(edades.mapNotNull { it.toIntOrNull() })
println(listOfNotNull("Ana", null, "Eva"))
val sinLista: List<String>? = null
println(sinLista?.size ?: 0)
println(sinLista.orEmpty().size)
}salida[sí, no, sí] 2 [31, 45] [Ana, Eva] 0 0
lateinit: inicializar más tarde
A veces una propiedad no puede tener valor al crear el objeto, pero sabes que lo tendrá antes de usarse: pasa mucho en Android, donde las vistas se crean en onCreate, y en las pruebas, con lo que prepara cada test. Para no declararla como String? y arrastrar interrogaciones, existe lateinit var:
class Sesion {
lateinit var usuario: String
fun entrar(nombre: String) {
usuario = nombre
}
fun saludo(): String =
if (::usuario.isInitialized) "Hola, $usuario" else "Nadie ha entrado"
}
fun main() {
val s = Sesion()
println(s.saludo())
s.entrar("Ana")
println(s.saludo())
val otra = Sesion()
println(otra.usuario)
}salida con errorNadie ha entrado
Hola, Ana
Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property usuario has not been initialized
at Sesion.getUsuario(lateinit.kt:2)Si la lees antes de asignarla, el fallo no es un NullPointerException genérico, sino una excepción que dice el nombre de la propiedad. ::usuario.isInitialized permite preguntarlo antes. lateinit solo vale para var y para tipos que no sean primitivos: no existe lateinit var n: Int.
Llamar a código Java: los tipos de plataforma
Kotlin y Java conviven en el mismo proyecto: Android y Spring están escritos en buena parte en Java, y desde Kotlin se usan sus clases como si fueran propias (si vienes de ahí, el curso de Java te dará el contexto). El problema es que en Java un String puede ser null y el código no lo dice. Esta clase de Java devuelve null cuando no conoce a la persona:
import java.util.HashMap;
import java.util.Map;
public class Contactos {
private static final Map<String, String> correos = new HashMap<>();
static {
correos.put("ana", "ana@example.com");
}
// Devuelve null si no conoce a la persona
public static String correoDe(String nombre) {
return correos.get(nombre);
}
}Kotlin no sabe si correoDe puede devolver null, así que le da un tipo de plataforma, que en los mensajes del compilador aparece como String!: ni String ni String?. Con él, Kotlin te deja elegir, y la responsabilidad pasa a ser tuya:
fun main() {
val seguro: String? = Contactos.correoDe("luis")
println("Seguro: ${seguro ?: "sin correo"}")
val directo = Contactos.correoDe("ana")
println("Directo: ${directo.length} letras")
val confiado: String = Contactos.correoDe("luis")
println("Esto ya no se escribe: $confiado")
}salida con errorSeguro: sin correo
Directo: 15 letras
Exception in thread "main" java.lang.NullPointerException: correoDe(...) must not be null
at PlataformaKt.main(plataforma.kt:8)Tres maneras de recibir lo mismo. Si lo guardas en String?, el compilador te obliga a manejar el null y no hay sorpresas. Si dejas el tipo de plataforma (directo), puedes llamar a length sin comprobar nada, como en Java, y si llegara null fallaría al usarlo. Si lo declaras String, Kotlin mete una comprobación en la asignación: el fallo sale en esa línea y con un mensaje que nombra el método de Java culpable, en lugar de aparecer mucho después.
Y en sentido contrario: un método de Java que recibe un String acepta que le pases null desde Kotlin, porque para Kotlin ese parámetro también es de plataforma. Entonces el NullPointerException salta dentro del código de Java, con su mensaje de siempre:
public class Texto {
public static int largo(String s) {
return s.length();
}
}fun main() {
println(Texto.largo("Kotlin"))
val nada: String? = null
println(Texto.largo(nada))
}salida con error6
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<parameter1>" is null
at Texto.largo(Texto.java:3)
at Npe_en_javaKt.main(npe_en_java.kt:4)Es el mensaje detallado de NullPointerException que da Java moderno, el mismo que verás en la lección de tipos del curso de Java: dice qué método no se pudo llamar y sobre qué. Aquí el parámetro aparece como <parameter1> y no como s porque esa clase se compiló sin información de depuración; con javac -g, o desde un IDE, saldría su nombre. La traza muestra que el fallo ocurrió en Texto.largo, el código de Java, llamado desde main en Kotlin.
Con anotaciones, Kotlin sí sabe
Cada vez más bibliotecas de Java anotan sus métodos para decir si pueden devolver null. Spring Framework 7 y muchas bibliotecas usan las anotaciones de JSpecify, y Android las suyas, @Nullable de AndroidX. Kotlin las lee, y entonces el tipo ya no es de plataforma. Esta clase de Java usa @Nullable de JSpecify:
import org.jspecify.annotations.Nullable;
public class Agenda {
public static @Nullable String telefonoDe(String nombre) {
return nombre.equals("ana") ? "55 5555 0100" : null;
}
}El error que te ahorra: olvidar que un método de Java anotado puede devolver null
fun main() {
val tel: String = Agenda.telefonoDe("luis")
println(tel.length)
}kotlincjspecify.kt:2:21: error: initializer type mismatch: expected 'String', actual 'String?'.
val tel: String = Agenda.telefonoDe("luis")
^Como el método está anotado, telefonoDe devuelve String? para Kotlin, y asignarlo a un String ya no compila. Con kotlinc 2.4.21, un desajuste con las anotaciones de JSpecify es un error, no un simple aviso. Es el mismo tipo de error que con código Kotlin: el null se detiene antes de ejecutar.
Si vienes de Java: Optional, anotaciones y null safety
En Java, la defensa contra el null es una mezcla de disciplina, Optional<T> para los valores de retorno, anotaciones y comprobaciones con Objects.requireNonNull. En Kotlin, T? hace el papel de Optional sin crear un objeto envoltorio, y ?., ?: y let equivalen a map, orElse e ifPresent. Desde Kotlin puedes seguir usando Optional si una API de Java te lo da, pero en código Kotlin nuevo no hace falta.
Resumen de la lección
- QuieresEscribeResultado si es null
- Que una variable pueda ser null
val x: String? = nullcompila;Stringsin?no - Usarla sin fallar
x?.lengthnull - Un valor por omisión
x ?: "nada"lo de la derecha - Salir de la función si falta
val y = x ?: returntermina la función - Hacer algo solo si hay valor
x?.let { ... }no se ejecuta - Comprobar y usar
if (x != null) x.lengthsmart cast dentro del if - Forzar (con cuidado)
x!!.lengthNullPointerException - Convertir sin excepción
v as? Stringnull - Quitar los null de una lista
lista.filterNotNull()lista sin null - Inicializar después
lateinit var x: Stringexcepción con el nombre
Retos
Reto 1. Escribe fun iniciales(nombre: String?, apellido: String?): String que devuelva las iniciales en mayúsculas, como «AL». Si falta alguno de los dos, usa «?» en su lugar. Sin usar !!.
Ver la solución
fun iniciales(nombre: String?, apellido: String?): String {
val a = nombre?.firstOrNull()?.uppercaseChar() ?: '?'
val b = apellido?.firstOrNull()?.uppercaseChar() ?: '?'
return "$a$b"
}
fun main() {
println(iniciales("ana", "lópez"))
println(iniciales("luis", null))
println(iniciales(null, ""))
}salidaAL L? ??
firstOrNull() devuelve Char?: null si la cadena está vacía. Así se cubren los dos casos, que falte el texto y que esté vacío, con una sola cadena de llamadas seguras.
Reto 2. Tienes una lista de textos que vienen de un formulario: listOf("120", null, "abc", "80", ""). Suma solo los que sean números válidos, en una sola expresión.
Ver la solución
fun main() {
val campos: List<String?> = listOf("120", null, "abc", "80", "")
val total = campos.mapNotNull { it?.toIntOrNull() }.sum()
println(total)
}salida200
mapNotNull aplica la función y descarta los resultados null: tanto los campos que ya eran null como los que no se pudieron convertir.
Reto 3. ¿Qué escribe este programa? Piénsalo antes de ver la solución. val a: String? = null; val b = a?.length ?: -1; val c = a.orEmpty().length; println("$b $c")
Ver la solución
fun main() {
val a: String? = null
val b = a?.length ?: -1
val c = a.orEmpty().length
println("$b $c")
}salida-1 0
a?.length da null y el Elvis lo cambia por -1. orEmpty() es una función de extensión sobre String? que convierte null en cadena vacía, por eso se puede llamar sin ?.; su largo es 0. Verás cómo se escriben funciones así en la lección 6.
Todo el código de esta lección se compiló con Kotlin 2.4.21 (kotlinc) y se ejecutó en la JVM de Temurin 25.0.5 en un contenedor limpio y sin red antes de publicarla; los mensajes del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con kotlinc -version.
