Cuando trabajas con tipos no nulables en Dart, el compilador es extremadamente estricto: si declaras una variable String, esa variable debe tener un valor antes de que el programa termine de ejecutarse. Sin embargo, en el mundo real, muchas inicializaciones dependen de lógica que ocurre en tiempo de ejecución, como una llamada a una API o la inyección de una dependencia en un método init().
Aquí es donde entra late [disponible desde Dart 2.12]. Básicamente, es un contrato que firmas con el compilador: le prometes que, aunque la variable no tenga un valor en el momento exacto de su declaración, lo tendrá antes de que alguien intente leerla. Esto evita la contaminación de tipos; sin late, te verías obligado a declarar la variable como nulable (String?) y luego tendrías que usar el operador de aserción nula (!) o validaciones if (x != null) en cada acceso, ensuciando la lógica de negocio con chequeos que, en la práctica, sabes que nunca fallarán.
Existen dos mecánicas fundamentales bajo late. La primera es la inicialización diferida (deferred initialization), donde declaras la variable y le asignas un valor manualmente más adelante. La segunda es la inicialización perezosa (lazy initialization), que ocurre cuando usas late final con un inicializador; en este caso, el valor no se calcula hasta que se solicita por primera vez, lo cual es una optimización crucial para recursos costosos.
Si rompes este contrato y accedes a una variable late antes de asignarle un valor, el runtime lanzará una excepción LateInitializationError. No es un error de compilación, lo que lo hace peligroso, porque el código “parece” estar bien, pero fallará en producción cuando el flujo de ejecución sea distinto.
class ConfigManager {
// 1. Lazy Initialization: Este método solo se ejecuta
// cuando alguien accede a 'heavySettings'.
late final Map<String, String> heavySettings = _loadExpensiveSettings();
// 2. Deferred Initialization: Se asignará mediante el método 'setup'.
late String apiToken;
// 3. Reasignable: Puede cambiar su valor después de la asignación.
late String currentSessionId;
void _log(String msg) => print('[ConfigManager] $msg');
Map<String, String> _loadExpensiveSettings() {
print('... Ejecutando carga pesada de configuración ...');
return {'theme': 'dark', 'api_version': 'v3'};
}
void setup(String token, String sessionId) {
print('Iniciando configuración...');
apiToken = token; // Asignación diferida
currentSessionId = sessionId; // Asignación diferida
_log('Setup completado.');
}
void printStatus() {
print('Token actual: $apiToken');
print('Session: $currentSessionId');
// El acceso a heavySettings dispara la ejecución de _loadExpensiveSettings
print('Settings cargadas: $heavySettings');
}
}
void main() {
final config = ConfigManager();
print('--- Escenario 1: Acceso antes de setup (Error esperado) ---');
// Si intentamos acceder a apiToken aquí, lanzará LateInitializationError.
// config.printStatus(); // Esto causaría un crash.
print('\n--- Escenario 2: Flujo correcto ---');
config.setup('abc-123', 'sess-999');
print('\n--- Escenario 3: Carga perezosa (Lazy) ---');
// Observa que _loadExpensiveSettings NO se ha ejecutado aún.
print('Antes de pedir settings...');
// Solo aquí se ejecuta la lógica pesada.
print('Settings: ${config.heavySettings}');
print('\n--- Escenario 4: Acceso repetido ---');
// La carga pesada NO se repite; el valor ya está cacheado.
print('Settings (otra vez): ${config.heavySettings}');
}
Desglose técnico
En el ejemplo, observa cómo manejamos los tres casos de uso de late.
En heavySettings, hemos usado late final con un inicializador (= _loadExpensiveSettings()). Esto es lazy loading puro. El compilador de Dart no ejecuta esa función al instanciar ConfigManager. En su lugar, genera internamente un getter que comprueba si el espacio de memoria ya contiene un valor; si no es así, ejecuta la función y guarda el resultado. Por eso, en el main, la “carga pesada” solo aparece en el Escenario 3 y no en el Escenario 2.
apiToken y currentSessionId representan la inicialización diferida. Aquí no hay un valor inicial, por lo que la variable es técnicamente inválida hasta que el método setup sea invocado. A diferencia de heavySettings, estas son variables late String (no final), lo que significa que su contenido puede cambiar durante el ciclo de vida del objeto.
Es importante notar que el uso de late en campos de instancia es la forma estándar de manejar dependencias que no se pueden inyectar en el constructor (por ejemplo, cuando usas un framework que instancia las clases por ti).
El error más sutil ocurre cuando confundes el propósito de late. Si una variable puede ser nula por lógica de negocio (por ejemplo, un usuario que aún no ha iniciado sesión), no uses late. Usa un tipo nulable String?. late debe usarse únicamente cuando la arquitectura del programa garantiza la inicialización, pero el sistema de tipos de Dart no puede rastrear esa garantía de forma estática.
El error frecuente
El error más común es el olvido de una llamada de inicialización en flujos de error o ramas condicionales.
late String userRole;
void loadUser(User? user) {
if (user != null) {
userRole = user.role;
}
// Si user es null, userRole queda sin inicializar.
}
void checkAccess() {
// Si loadUser(null) fue llamada, esto lanza LateInitializationError.
if (userRole == 'admin') {
print('Acceso concedido');
}
}
Si el flujo de tu programa permite que loadUser sea saltado, checkAccess fallará estrepitosamente en tiempo de ejecución. Si existe la posibilidad de que la variable no tenga valor, la solución correcta es usar String? y manejar la nulidad explícitamente.
N° 30