Null Safety en Kotlin: ?, !!, let y Elvis Operator

1. Descripción general

NullPointerException (NPE) es la pesadilla de todo desarrollador Java. Kotlin resuelve este problema en el momento de la compilación con un sistema de null safety integrado en el type system. Este artículo te ayuda a comprender cuatro herramientas clave: el signo ? para declarar nullable types, la safe call ?., el elvis operator ?: y la función let. Después de leerlo, sabrás cuándo usar cada herramienta y evitarás la trampa clásica: usar !! de forma desenfrenada y aún así encontrarte con NPE.

2. Nullable Types y el signo ?

En Kotlin, todo type es non-null por defecto. Si quieres que una variable pueda contener null, debes declararlo explícitamente con el signo ? después del type.

class Customer(val name: String, val email: String?)

fun main() {
    val customer = Customer("An", null)
    println(customer.email) // null, sin error porque email es String?
}
  • name: String — nunca puede ser null, el compilador lo garantiza.
  • email: String? — puede ser null, el compilador te obliga a manejarlo en todo lugar donde se use.

Si intentas llamar un método directamente en email como customer.email.length, el compilador reportará un error de inmediato:

error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?

Esta es la mayor ventaja: los errores se detectan en tiempo de compilación, no cuando la app se ejecuta en producción.

3. Safe Call ?.

?. llama un método o accede a una propiedad solo si el receiver no es null. Si el receiver es null, toda la expresión devuelve null, sin lanzar excepciones.

class Order(val id: Long, val customer: Customer?)

fun printCustomerName(order: Order) {
    val length = order.customer?.name?.length
    println("Longitud del nombre: $length")
}
  • order.customer?.name — si customer es null, toda la expresión se cortocircuita y devuelve null, sin ejecutar .length.
  • El resultado length tiene type Int?, porque cada paso en la cadena ?. puede devolver null.
Longitud del nombre: null   // cuando customer == null
Longitud del nombre: 2       // cuando customer.name = "An"

Nota: Una cadena ?. que exceda 3-4 pasos suele ser un signo de que el diseño del domain model necesita mejoras. Considera dividir la función o usar sealed classes en lugar de dejar nullables propagarse.

3.1. Safe Call combinado con llamadas a funciones

?. también funciona con funciones, no solo con properties:

fun notifyCustomer(order: Order) {
    order.customer?.let { sendEmail(it) }
}

fun sendEmail(customer: Customer) {
    println("Enviando email a ${customer.email}")
}

La línea order.customer?.let { ... } solo ejecuta el bloque let cuando customer no es null. Esta parte se relaciona directamente con la sección de let en la parte 5.

4. Elvis Operator ?:

El Elvis operator proporciona un valor por defecto cuando la expresión de la izquierda es null. El nombre proviene de la forma ?: que se parece al peinado de Elvis Presley visto de lado.

fun getDisplayEmail(customer: Customer): String {
    return customer.email ?: "email no proporcionado"
}
  • Si customer.email != null, devuelve ese valor.
  • Si es null, devuelve la cadena fallback "email no proporcionado".

El Elvis operator es aún más poderoso cuando se combina con return o throw, permitiendo early-returns limpios:

fun validateOrder(order: Order): Customer {
    val customer = order.customer ?: throw IllegalStateException("Orden ${order.id} requiere customer")
    return customer
}

La línea ?: throw IllegalStateException(...) hace que customer luego sea inferido por el compilador como type non-null Customer, ya no es Customer?. Esta es una técnica muy común para “desempacar” nullable temprano en una función.

4.1. Elvis con funciones seguras por defecto

fun calculateDiscount(order: Order): Double {
    val tier = order.customer?.name?.length ?: 0
    return if (tier > 5) 0.1 else 0.0
}

Aquí, tanto la cadena de safe call como el elvis se combinan: si algún paso devuelve null, el resultado final recurre a 0.

5. La función let para procesar valores non-null

let es una scope function: recibe un receiver, ejecuta un bloque y devuelve el resultado del bloque. Combinado con ?., se convierte en un patrón clásico para ejecutar código solo cuando el valor no es null.

fun processPayment(amount: Double?) {
    amount?.let {
        println("Procesando pago: $it")
        chargeCard(it)
    }
}

fun chargeCard(value: Double) {
    println("Se debitó $value de la tarjeta")
}
  • amount?.let { ... } — el bloque interno solo se ejecuta si amount != null.
  • Dentro del bloque, it tiene type Double (non-null), no Double?, así que puedes llamar a chargeCard(it) sin problemas, sin necesidad de !! o verificaciones adicionales.

Comparado con escribir if (amount != null) { ... }, usar let ayuda a limitar el scope de la variable temporal, evita que la variable se escape fuera del bloque y hace que el código sea más tipo “pipeline”.

5.1. Combinando let con Elvis para devolver un valor

fun formatAmount(amount: Double?): String {
    return amount?.let { "Monto: %.2f".format(it) } ?: "Sin monto"
}

Esta línea se lee naturalmente: “si hay amount entonces formatea, si no devuelve el fallback”. Este es un idiom muy común en código Kotlin real, especialmente cuando mapeas datos de DTOs a domain models.

6. Non-null Assertion !! — ¿Cuándo usarlo?

!! obliga al compilador a tratar un nullable type como non-null. Si el valor es realmente null, lanza NullPointerException de inmediato.

fun getCustomerNameUnsafe(order: Order): String {
    return order.customer!!.name
}
  • order.customer!! — si customer es null, esta línea lanza NPE con un mensaje bastante claro:
Exception in thread "main" java.lang.NullPointerException
    at MainKt.getCustomerNameUnsafe(Main.kt:2)

Nota: !! es esencialmente traer NPE de Java de vuelta a Kotlin manualmente. Úsalo solo cuando estés 100% seguro de que el valor no es null en ese momento (por ejemplo, después de una validación anterior), y es mejor añadir un comentario explicando por qué.

Cuándo es aceptable usar !!:

  • Interoperabilidad con código Java antiguo, API devuelve @Nullable pero sabes con certeza que la lógica de negocio garantiza que no sea null.
  • Código de prueba, cuando quieres fallar rápidamente si la configuración es incorrecta.

Cuándo no debes usar !!:

  • Procesar entrada del usuario, solicitudes HTTP, base de datos — siempre puede ser null inesperadamente.
  • Reemplazar la lógica de validación. Usa requireNotNull() o elvis + exception más explícita.
fun getCustomerName(order: Order): String {
    val customer = requireNotNull(order.customer) { "Orden ${order.id} requiere customer" }
    return customer.name
}

requireNotNull desempaqueta el nullable y proporciona un mensaje de error mucho más claro que !! — esta es la opción que debe priorizarse sobre !! sin más.

7. Comparación rápida de las cuatro herramientas

Herramienta Cuándo usarlo Comportamiento cuando encuentra null
? Declarar type que puede ser null N/A, solo es una anotación de tipo
?. Acceso seguro, cuando no estás seguro de null Devuelve null, no lanza excepciones
?: Necesitas valor fallback o early-return Ejecuta la expresión de la derecha
let Ejecutar lógica solo cuando hay valor No ejecuta el bloque
!! 100% seguro de que no es null Lanza NullPointerException

8. Escribir tests para funciones que manejan null

Para asegurar que la lógica de null-safety funciona correctamente, escribe tests para ambas ramas: con valor y null.

import org.junit.jupiter.api.Test
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy

class OrderServiceTest {

    @Test
    fun `getDisplayEmail devuelve fallback cuando email es null`() {
        val customer = Customer(name = "An", email = null)
        val result = getDisplayEmail(customer)
        assertThat(result).isEqualTo("email no proporcionado")
    }

    @Test
    fun `getDisplayEmail devuelve email cuando tiene valor`() {
        val customer = Customer(name = "An", email = "an@example.com")
        val result = getDisplayEmail(customer)
        assertThat(result).isEqualTo("an@example.com")
    }

    @Test
    fun `validateOrder lanza cuando customer es null`() {
        val order = Order(id = 1L, customer = null)
        assertThatThrownBy { validateOrder(order) }
            .isInstanceOf(IllegalStateException::class.java)
            .hasMessageContaining("requiere customer")
    }
}
  • La primera prueba verifica que elvis devuelve el fallback correcto cuando email == null.
  • La segunda prueba verifica el caso opuesto — que no se pierda el valor real.
  • La última prueba verifica que ?: throw lanza la excepción correcta y el mensaje esperado, usando assertThatThrownBy de AssertJ.

Dependencias necesarias para los tests (si usas Maven):

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>

9. Errores comunes

Abusar de !! para “silenciar al compilador”. Cuando ves que el compilador reporta un error nullable y automáticamente escribes !!, detente — eso es un signo de que no entiendes bien el flujo de datos. Pregúntate: ¿este valor puede ser realmente null? Si es así, manéjalo con ?: o let.

Usar ?.let en lugar de if-else de forma mecánica. Con lógica compleja que tiene muchas ramas, if (x != null) { } else { } a veces es más claro que let anidado. No fuerces todo en elvis/let si el código se vuelve más difícil de leer.

Olvidar que los platform types de Java no tienen null safety. Cuando llamas un método Java que devuelve un objeto sin anotaciones @Nullable/@NonNull, Kotlin lo trata como platform type (String!), el compilador no te obliga a verificar null. NPE aún puede ocurrir en runtime en este caso — añade manualmente ?. o verifica cuando trabajas con Java interop.

// Java: public String getLegacyName() { return null; } // sin anotaciones
val name = legacyService.getLegacyName() // el tipo se infiere como String!, Kotlin no advierte
println(name.length) // puede ser NPE en runtime si name es null

10. Conclusión

La null safety de Kotlin no es “magia” para eliminar completamente NPE, sino una herramienta para detectar errores más temprano — en tiempo de compilación en lugar de runtime. El signo ? declara posibilidad de null, ?. accede de forma segura, ?: proporciona fallback o early-return, let limita el scope de la lógica cuando hay valor. !! es la salida de emergencia, solo debe usarse cuando estés seguro y dispuesto a aceptar que lance NPE si te equivocas. Domina estas cuatro herramientas y escribirás código Kotlin mucho más seguro que con los hábitos de null-check estilo Java antiguo.

kotlinnull-safetybest-practicesjava-interop