La jerarquía de la nulidad: T vs T?

La jerarquía de la nulidad: T vs T?

En Dart, la distinción entre un tipo T y un tipo T? es la base de la seguridad del sistema de tipos. Un tipo T (non-nullable) es una promesa absoluta de que el valor siempre existirá; es decir, la variable siempre contendrá una instancia de T. Por el contrario, T? (nullable) es la unión de T con el tipo Null, lo que significa que la variable puede contener un valor de tipo T o puede ser null.

Esta arquitectura responde a la necesidad de soundness (seguridad de tipos): el compilador necesita garantizar que, si tú llamas a un método en un objeto, ese objeto realmente existe. Si intentas asignar un String? donde se requiere un String, el compilador te detendrá para evitar que el programa falle inesperadamente al intentar acceder a una propiedad de algo que no es nada. Esta distinción es vital cuando trabajas con datos externos (como respuestas de una API o bases de datos) donde la ausencia de un valor es una posibilidad real. Si ignoras esta jerarquía mediante aserciones forzadas (como el operador !), corres el riesgo de que tu aplicación lance una excepción en tiempo de ejecución cuando la realidad no coincida con tu suposición.

Para entenderlo visualmente, debemos mirar la raíz de la jerarquía de tipos. En Dart, Object es la raíz de todos los tipos no nulos; cualquier objeto que no sea null hereda de Object. Sin embargo, Object? es la verdadera raíz de todo el sistema, ya que es el único tipo que puede contener tanto cualquier objeto como el valor null. Por ello, existe una relación de subtipado unidireccional: un int es un subtipo de int? (porque un entero es un tipo de “algo que puede ser nulo”), pero un int? no es un subtipo de int (porque un nulo no es un entero).

Cuando trabajes con colecciones genéricas, ten mucho cuidado con la posición del signo de interrogación, ya que cambia drásticamente el contrato de la lista: List<String> no acepta elementos nulos, List<String?> permite que sus elementos sean null, pero List<String>? significa que la lista entera podría ser nula.

class Profile {
  final String id;         // Non-nullable: Siempre debe haber un ID
  final String? bio;       // Nullable: El usuario puede no tener biografía
  final List<String> tags; // Non-nullable: La lista existe, aunque esté vacía

  Profile({
    required this.id,
    this.bio,
    required this.tags,
  });
}

void main() {
  final user = Profile(
    id: 'user_123',
    bio: null,
    tags: ['dart', 'backend'],
  );

  // 1. El problema del "Nullable Receiver"
  // print(user.bio.length); // Error: No puedes llamar métodos directamente en un T?
  
  // 2. Solución mediante Null-check manual (Type Promotion)
  if (user.bio != null) {
    // Aquí, dentro del bloque, el compilador "promociona" bio de String? a String
    print('Bio length: ${user.bio.length}');
  }

  // 3. Solución mediante Operadores Null-aware
  // Usamos ?. para navegar de forma segura y ?? para proveer un valor por defecto
  final bioLength = user.bio?.length ?? 0;
  print('Bio length (safe): $bioLength');

  // 4. Comportamiento de Genéricos en colecciones
  final List<String> mandatoryTags = ['dev'];
  // mandatoryTags.add(null); // Error de compilación: String no acepta null

  final List<String?> optionalTags = ['dev', null]; // Permitido: contiene nulos
  
  final List<String>? nullableList = null; // Permitido: la variable es nula

  // 5. El peligro de la aserción forzada (!)
  // Si estás 100% seguro de que no es nulo, puedes usar !
  // Pero si te equivocas, el programa explota en runtime.
  final String forcedBio = user.bio!; // ¡Cuidado! Esto lanzará un error si bio es null
}

En el ejemplo anterior, observa cómo el compilador actúa como un guardián. Al declarar id como String, el motor de Dart garantiza que cualquier acceso a user.id es seguro. Sin embargo, con bio, el compilador te obliga a tratar la posibilidad de la nulidad.

Cuando haces el chequeo if (user.bio != null), ocurre un proceso llamado type promotion. El analizador estático de Dart detecta que, dado que el valor es nulo, el flujo de control no entrará al bloque si es null, permitiéndote usar user.bio.length como si fuera un String normal. Esto es mucho más eficiente y seguro que el uso indiscriminado de operadores de aserción.

Por otro lado, el operador ?. (null-aware access) es fundamental para el código limpio. En lugar de anidar if constantes, user.bio?.length detiene la ejecución de la cadena en cuanto encuentra un null, devolviendo null como resultado de la expresión completa. Si combinamos esto con ?? (null-coalescing operator), transformamos una posibilidad de error en un valor seguro y predecible.

Finalmente, fíjate en la distinción de las listas. List<String> es un contrato estricto: la lista existe y sus elementos son strings. List<String?> es un contrato relajado: la lista existe, pero sus compartimentos pueden estar vacíos. List<String>? es el contrato más volátil: ni siquiera puedes estar seguro de que la lista ha sido inicializada.

El error frecuente
Un error común es intentar “limpiar” el código usando el operador de aserción forzada ! para evitar los avisos del compilador.

// Código problemático
void printBio(String? bio) {
  // El desarrollador cree que 'bio' nunca será null porque "viene de la DB"
  print(bio!.toUpperCase()); 
}

void main() {
  printBio(null); // Runtime Error: TypeError: Cannot read 'toUpperCase' of null
}

Confiar en que un valor es no-nulo mediante ! es decirle al compilador: “Cállate y confía en mí”. Si esa confianza se rompe, el error no aparecerá al compilar, sino cuando el usuario esté usando la aplicación, lo que hace que sea mucho más difícil de detectar y depurar.

28

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio