- Configure workspace Git repository with comprehensive .gitignore - Add Odoo as submodule for ERP reference code - Include documentation: SETUP.md, GIT-STRUCTURE.md - Add gitignore templates for projects (backend, frontend, database) - Structure supports independent repos per project/subproject level Workspace includes: - core/ - Reusable patterns, modules, orchestration system - projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.) - knowledge-base/ - Reference code and patterns (includes Odoo submodule) - devtools/ - Development tools and templates - customers/ - Client implementations template 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
555 lines
14 KiB
Markdown
555 lines
14 KiB
Markdown
# ADR-014: Adopción de Nil-Safety Patterns con Optional Chaining y Nullish Coalescing
|
|
|
|
**Estado:** ✅ Aceptado
|
|
**Fecha:** 2025-11-23
|
|
**Autores:** Frontend-Developer, Architecture-Analyst
|
|
**Decisión:** Adoptar Optional Chaining (`?.`) y Nullish Coalescing (`??`) como patrón estándar para manejo de valores null/undefined
|
|
**Tags:** frontend, typescript, patterns, nil-safety, best-practices
|
|
|
|
---
|
|
|
|
## Contexto
|
|
|
|
El frontend de GAMILIT maneja datos que pueden ser `null` o `undefined` en múltiples escenarios:
|
|
- API responses durante loading states
|
|
- Datos opcionales del usuario (perfil incompleto)
|
|
- Datos anidados profundos (user.profile.gamification.rank)
|
|
- Arrays que pueden estar vacíos o undefined
|
|
|
|
### Situación Inicial
|
|
|
|
**Problema:** Sin un patrón estándar para manejar valores null/undefined
|
|
|
|
**Variantes encontradas en el código:**
|
|
|
|
```typescript
|
|
// Variante 1: Logical OR (||) - INCORRECTA
|
|
const rank = user.gamification.rank || 'Sin rango';
|
|
// ❌ Falla si rank = 0 o rank = ''
|
|
|
|
// Variante 2: Ternary con checks manuales - VERBOSE
|
|
const rank = user && user.gamification && user.gamification.rank
|
|
? user.gamification.rank
|
|
: 'Sin rango';
|
|
// ❌ Difícil de leer
|
|
|
|
// Variante 3: Try-catch - ANTI-PATTERN
|
|
try {
|
|
const rank = user.gamification.rank;
|
|
} catch {
|
|
const rank = 'Sin rango';
|
|
}
|
|
// ❌ Try-catch NO es para control de flujo
|
|
|
|
// Variante 4: Lodash get - DEPENDENCIA INNECESARIA
|
|
import { get } from 'lodash';
|
|
const rank = get(user, 'gamification.rank', 'Sin rango');
|
|
// ❌ Requiere lodash (+25 KB)
|
|
```
|
|
|
|
### Problemas Identificados
|
|
|
|
#### 1. TypeError: Cannot Read Property
|
|
|
|
**Error más común en producción:**
|
|
```
|
|
TypeError: Cannot read property 'rank' of undefined
|
|
at TeacherStudentsPage.tsx:42
|
|
```
|
|
|
|
**Causa:** Acceso a propiedades sin validar null/undefined
|
|
|
|
```typescript
|
|
// ❌ Código propenso a crash
|
|
const rank = user.gamification.rank;
|
|
// ^^^^^^^^^^^^
|
|
// Si gamification es undefined → CRASH
|
|
```
|
|
|
|
#### 2. Logical OR (||) Con Falsy Values
|
|
|
|
**Bug real (BUG-FRONTEND-005):**
|
|
|
|
```typescript
|
|
// ❌ Lógica incorrecta
|
|
const count = comodinesInventory.pistas_count || 0;
|
|
|
|
// Si pistas_count = 0 (tiene 0 pistas)
|
|
// → count = 0 (CORRECTO por suerte)
|
|
|
|
// PERO si queremos mostrar:
|
|
const message = user.name || 'Anónimo';
|
|
// Si name = '' (string vacío válido)
|
|
// → message = 'Anónimo' ❌ (debería ser '')
|
|
```
|
|
|
|
**Problema:** `||` considera TODOS los falsy values (0, '', false, null, undefined)
|
|
|
|
#### 3. Nested Checks Verbosos
|
|
|
|
```typescript
|
|
// ❌ Código repetitivo y difícil de leer
|
|
const avatar = user && user.profile && user.profile.avatar
|
|
? user.profile.avatar
|
|
: '/default-avatar.png';
|
|
|
|
// 4 líneas para un simple null check
|
|
```
|
|
|
|
---
|
|
|
|
## Decisión
|
|
|
|
**Adoptamos Optional Chaining (`?.`) + Nullish Coalescing (`??`)** como patrón estándar para nil-safety.
|
|
|
|
### Sintaxis
|
|
|
|
**Optional Chaining (`?.`):**
|
|
```typescript
|
|
// Acceso seguro a propiedades
|
|
user?.profile?.avatar
|
|
|
|
// Equivale a:
|
|
user !== null && user !== undefined
|
|
&& user.profile !== null && user.profile !== undefined
|
|
? user.profile.avatar
|
|
: undefined
|
|
```
|
|
|
|
**Nullish Coalescing (`??`):**
|
|
```typescript
|
|
// Default value SOLO para null/undefined
|
|
value ?? defaultValue
|
|
|
|
// Equivale a:
|
|
value !== null && value !== undefined
|
|
? value
|
|
: defaultValue
|
|
```
|
|
|
|
### Implementación Estándar
|
|
|
|
**Patrón recomendado:**
|
|
```typescript
|
|
// ✅ CORRECTO: Optional chaining + Nullish coalescing
|
|
const rank = user?.gamification?.rank ?? 'Sin rango';
|
|
```
|
|
|
|
**Por qué funciona:**
|
|
1. `user?.gamification?.rank` retorna el valor o `undefined`
|
|
2. `?? 'Sin rango'` aplica default SOLO si es `null` o `undefined`
|
|
3. Respeta falsy values válidos (0, '', false)
|
|
|
|
---
|
|
|
|
## Alternativas Consideradas
|
|
|
|
### Alternativa 1: Logical OR (`||`)
|
|
|
|
**Descripción:** Usar operador OR lógico para default values
|
|
|
|
**Pros:**
|
|
- ✅ Sintaxis corta
|
|
- ✅ Soportado en ES5+ (no requiere transpiling)
|
|
|
|
**Cons:**
|
|
- ❌ Falla con falsy values válidos (0, '', false)
|
|
- ❌ Comportamiento inesperado para principiantes
|
|
- ❌ No es semánticamente correcto (|| es para lógica booleana)
|
|
|
|
**Ejemplo:**
|
|
```typescript
|
|
const count = inventory.pistas_count || 0;
|
|
// ❌ Si count = 0 → retorna 0 (correcto por casualidad)
|
|
// ❌ Si name = '' → retorna 'Anónimo' (INCORRECTO)
|
|
```
|
|
|
|
**Veredicto:** ❌ **RECHAZADA** - Comportamiento inconsistente con falsy values
|
|
|
|
---
|
|
|
|
### Alternativa 2: Default Parameters
|
|
|
|
**Descripción:** Usar default parameters en funciones
|
|
|
|
**Pros:**
|
|
- ✅ Type-safe
|
|
- ✅ Semánticamente correcto
|
|
- ✅ Solo aplica para undefined (no null)
|
|
|
|
**Cons:**
|
|
- ⚠️ Solo funciona en function parameters (no object properties)
|
|
- ❌ No sirve para nested access
|
|
|
|
**Ejemplo:**
|
|
```typescript
|
|
function greet(name: string = 'Anónimo') {
|
|
return `Hola, ${name}`;
|
|
}
|
|
|
|
// ✅ Funciona para parameters
|
|
greet(); // 'Hola, Anónimo'
|
|
greet(undefined); // 'Hola, Anónimo'
|
|
|
|
// ❌ NO funciona para object access
|
|
const rank = user.gamification?.rank = 'Sin rango'; // Syntax error
|
|
```
|
|
|
|
**Veredicto:** ⚠️ **USO LIMITADO** - Solo para function parameters, no reemplaza nil-safety
|
|
|
|
---
|
|
|
|
### Alternativa 3: Lodash `get()`
|
|
|
|
**Descripción:** Usar `_.get()` de Lodash para safe property access
|
|
|
|
**Pros:**
|
|
- ✅ Safe deep property access
|
|
- ✅ Default value support
|
|
- ✅ Battle-tested (usado por millones)
|
|
|
|
**Cons:**
|
|
- ❌ Requiere dependencia externa (+25 KB con tree-shaking, +70 KB sin)
|
|
- ❌ Performance overhead (función adicional)
|
|
- ❌ String-based paths (no type-safe)
|
|
- ❌ Menos legible que native syntax
|
|
|
|
**Ejemplo:**
|
|
```typescript
|
|
import { get } from 'lodash';
|
|
|
|
const rank = get(user, 'gamification.rank', 'Sin rango');
|
|
// ^^^^^^^^^^^^^^^^^^
|
|
// ❌ String path: no autocomplete, no type checking
|
|
```
|
|
|
|
**Veredicto:** ❌ **RECHAZADA** - Bundle size injustificado cuando TypeScript tiene sintaxis nativa
|
|
|
|
---
|
|
|
|
### Alternativa 4: Optional Chaining + Nullish Coalescing (TypeScript Native)
|
|
|
|
**Descripción:** Usar sintaxis nativa de TypeScript/ES2020
|
|
|
|
**Pros:**
|
|
- ✅ Sintaxis nativa (0 KB bundle overhead)
|
|
- ✅ Type-safe (TypeScript valida en compile-time)
|
|
- ✅ Legible y conciso
|
|
- ✅ Performance óptimo (compila a checks simples)
|
|
- ✅ Estándar ECMAScript (ES2020)
|
|
- ✅ Soportado por todos los browsers modernos
|
|
|
|
**Cons:**
|
|
- ⚠️ Requiere TypeScript 3.7+ (ya lo tenemos)
|
|
- ⚠️ Requires transpiling para IE11 (no es concern - no soportamos IE11)
|
|
|
|
**Ejemplo:**
|
|
```typescript
|
|
const rank = user?.gamification?.rank ?? 'Sin rango';
|
|
// ✅ Type-safe
|
|
// ✅ Autocomplete
|
|
// ✅ Compila a código eficiente
|
|
```
|
|
|
|
**Veredicto:** ✅ **SELECCIONADA** - Mejor opción en todos los aspectos
|
|
|
|
---
|
|
|
|
## Tabla Comparativa
|
|
|
|
| Característica | `||` | Default Params | Lodash `get()` | `?.` + `??` |
|
|
|----------------|------|----------------|----------------|-------------|
|
|
| **Bundle Size** | 0 KB | 0 KB | 25 KB | 0 KB |
|
|
| **Type-Safe** | ❌ No | ✅ Sí | ❌ No | ✅ Sí |
|
|
| **Falsy Values** | ❌ Falla | ✅ OK | ✅ OK | ✅ OK |
|
|
| **Deep Nesting** | ⚠️ Verbose | ❌ No | ✅ Sí | ✅ Sí |
|
|
| **Legibilidad** | ⚠️ Media | ✅ Alta | ⚠️ Media | ✅ Alta |
|
|
| **Performance** | ✅ Alta | ✅ Alta | ⚠️ Media | ✅ Alta |
|
|
| **Standard** | ES5 | ES6 | N/A | ES2020 |
|
|
|
|
**Conclusión:** Optional Chaining + Nullish Coalescing es la mejor opción.
|
|
|
|
---
|
|
|
|
## Consecuencias
|
|
|
|
### Positivas ✅
|
|
|
|
#### 1. Código Más Conciso
|
|
|
|
**Antes:**
|
|
```typescript
|
|
const rank = user && user.gamification && user.gamification.rank
|
|
? user.gamification.rank
|
|
: 'Sin rango';
|
|
```
|
|
|
|
**Después:**
|
|
```typescript
|
|
const rank = user?.gamification?.rank ?? 'Sin rango';
|
|
```
|
|
|
|
**Reducción:** 4 líneas → 1 línea (75% menos código)
|
|
|
|
#### 2. Type-Safety Garantizado
|
|
|
|
```typescript
|
|
// ✅ TypeScript valida tipos
|
|
interface User {
|
|
gamification?: {
|
|
rank?: string;
|
|
};
|
|
}
|
|
|
|
const rank = user?.gamification?.rank ?? 'Sin rango';
|
|
// ^^^^
|
|
// TypeScript sabe que rank es string | undefined
|
|
```
|
|
|
|
#### 3. Prevención de TypeError
|
|
|
|
**Antes (propenso a crashes):**
|
|
```typescript
|
|
const rank = user.gamification.rank; // ❌ Crash si gamification undefined
|
|
```
|
|
|
|
**Después (safe):**
|
|
```typescript
|
|
const rank = user?.gamification?.rank ?? 'Sin rango'; // ✅ Nunca crashea
|
|
```
|
|
|
|
#### 4. Zero Bundle Overhead
|
|
|
|
**Comparación:**
|
|
- Lodash `get()`: +25 KB
|
|
- Optional Chaining + Nullish Coalescing: **0 KB** (sintaxis nativa)
|
|
|
|
**Transpiled output (TypeScript → JavaScript):**
|
|
```javascript
|
|
// Input TypeScript
|
|
const rank = user?.gamification?.rank ?? 'Sin rango';
|
|
|
|
// Output JavaScript (ES2020 target)
|
|
const rank = user?.gamification?.rank ?? 'Sin rango';
|
|
|
|
// Output JavaScript (ES5 target - legacy)
|
|
var _a, _b;
|
|
const rank = (_b = (_a = user) === null || _a === void 0
|
|
? void 0
|
|
: _a.gamification) === null || _b === void 0
|
|
? void 0
|
|
: _b.rank) !== null && _b !== void 0
|
|
? _b
|
|
: 'Sin rango';
|
|
```
|
|
|
|
**Bundle impact:** Mínimo (<1% overhead en ES5 target)
|
|
|
|
---
|
|
|
|
### Negativas ⚠️
|
|
|
|
#### 1. Curva de Aprendizaje Inicial
|
|
|
|
**Conceptos nuevos:**
|
|
- Diferencia entre `?.` y `.`
|
|
- Diferencia entre `??` y `||`
|
|
- Cuándo usar cada operador
|
|
|
|
**Mitigación:**
|
|
- ✅ Documentación interna con ejemplos
|
|
- ✅ ESLint rule para detectar uso de `||` donde debería ser `??`
|
|
- ✅ Code reviews
|
|
|
|
#### 2. Puede Ocultar Bugs de Datos Faltantes
|
|
|
|
**Ejemplo:**
|
|
```typescript
|
|
// ❌ Anti-pattern: ocultar bugs
|
|
const rank = user?.gamification?.rank ?? 'Sin rango';
|
|
|
|
// Si gamification DEBERÍA existir siempre, este code oculta el bug
|
|
// Mejor validar explícitamente:
|
|
if (!user?.gamification) {
|
|
console.error('User missing gamification data');
|
|
Sentry.captureException(new Error('Missing gamification'));
|
|
}
|
|
const rank = user.gamification.rank ?? 'Sin rango';
|
|
```
|
|
|
|
**Guía:**
|
|
- ✅ Usar `?.` cuando datos son OPCIONALMENTE opcionales
|
|
- ❌ NO usar `?.` para "esconder" datos que DEBERÍAN existir
|
|
|
|
---
|
|
|
|
## Guía de Uso
|
|
|
|
### Caso 1: Nested Property Access
|
|
|
|
```typescript
|
|
// ✅ CORRECTO
|
|
const avatar = user?.profile?.avatar ?? '/default-avatar.png';
|
|
|
|
// ❌ INCORRECTO
|
|
const avatar = (user && user.profile && user.profile.avatar) || '/default-avatar.png';
|
|
```
|
|
|
|
### Caso 2: Array Length
|
|
|
|
```typescript
|
|
// ✅ CORRECTO
|
|
const count = students?.length ?? 0;
|
|
|
|
// ❌ INCORRECTO
|
|
const count = students ? students.length : 0;
|
|
```
|
|
|
|
### Caso 3: Falsy Values Válidos
|
|
|
|
```typescript
|
|
// ✅ CORRECTO (respeta 0)
|
|
const pistas = inventory?.pistas_count ?? 0;
|
|
// Si pistas_count = 0 → retorna 0 ✅
|
|
|
|
// ❌ INCORRECTO (falla con 0)
|
|
const pistas = inventory?.pistas_count || 0;
|
|
// Si pistas_count = 0 → retorna 0 (correcto por suerte, pero mal pattern)
|
|
```
|
|
|
|
### Caso 4: Optional Function Calls
|
|
|
|
```typescript
|
|
// ✅ CORRECTO
|
|
const result = obj?.method?.();
|
|
|
|
// Equivale a:
|
|
const result = obj && obj.method ? obj.method() : undefined;
|
|
```
|
|
|
|
---
|
|
|
|
## Anti-Patterns a Evitar
|
|
|
|
### Anti-Pattern 1: Usar `||` Para Default Values
|
|
|
|
```typescript
|
|
// ❌ MALO
|
|
const name = user.name || 'Anónimo'; // Falla si name = ''
|
|
|
|
// ✅ BUENO
|
|
const name = user.name ?? 'Anónimo'; // Respeta ''
|
|
```
|
|
|
|
### Anti-Pattern 2: Over-using Optional Chaining
|
|
|
|
```typescript
|
|
// ❌ MALO (oculta bugs)
|
|
const rank = user?.gamification?.rank ?? 'Sin rango';
|
|
// Si gamification SIEMPRE debería existir, esto esconde el bug
|
|
|
|
// ✅ BUENO (valida explícitamente)
|
|
if (!user.gamification) {
|
|
throw new Error('User missing gamification data');
|
|
}
|
|
const rank = user.gamification.rank ?? 'Sin rango';
|
|
```
|
|
|
|
### Anti-Pattern 3: Mixing `||` and `??`
|
|
|
|
```typescript
|
|
// ❌ CONFUSO
|
|
const value = data?.field || defaultValue ?? fallback;
|
|
|
|
// ✅ CLARO
|
|
const value = data?.field ?? defaultValue;
|
|
```
|
|
|
|
---
|
|
|
|
## Ejemplos Reales del Proyecto
|
|
|
|
### Ejemplo 1: TeacherStudentsPage
|
|
|
|
```typescript
|
|
// apps/frontend/src/apps/teacher/pages/TeacherStudents.tsx
|
|
|
|
// ✅ Safe rank display
|
|
const rank = student.user?.gamification?.rank ?? 'Sin rango';
|
|
|
|
// ✅ Safe student count
|
|
const studentCount = students?.length ?? 0;
|
|
|
|
// ✅ Safe classroom name
|
|
const classroomName = selectedClassroom?.name ?? 'Todos los estudiantes';
|
|
```
|
|
|
|
### Ejemplo 2: GamificationWidget
|
|
|
|
```typescript
|
|
// ✅ Safe XP display
|
|
const xp = gamificationData?.xp ?? 0;
|
|
|
|
// ✅ Safe coins display
|
|
const coins = gamificationData?.coins ?? 0;
|
|
|
|
// ✅ Safe achievements count
|
|
const achievementsCount = gamificationData?.achievements?.length ?? 0;
|
|
```
|
|
|
|
---
|
|
|
|
## TypeScript Configuration
|
|
|
|
**Requerido en tsconfig.json:**
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"strict": true,
|
|
"strictNullChecks": true, // ✅ REQUERIDO
|
|
"target": "ES2020", // ✅ Para native support
|
|
"lib": ["ES2020", "DOM"]
|
|
}
|
|
}
|
|
```
|
|
|
|
**Con `strictNullChecks: true`:**
|
|
- TypeScript diferencia `T` vs `T | null` vs `T | undefined`
|
|
- Fuerza uso explícito de null checks
|
|
- Previene accesos inseguros en compile-time
|
|
|
|
---
|
|
|
|
## ESLint Rules (Recomendadas)
|
|
|
|
```javascript
|
|
// .eslintrc.js
|
|
module.exports = {
|
|
rules: {
|
|
// Warn cuando se usa || con objetos (probablemente debería ser ??)
|
|
'prefer-nullish-coalescing': 'warn',
|
|
|
|
// Warn cuando se usa && para property access (debería ser ?.)
|
|
'prefer-optional-chain': 'warn',
|
|
},
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Referencias
|
|
|
|
- [TC39 Optional Chaining Proposal](https://github.com/tc39/proposal-optional-chaining)
|
|
- [TC39 Nullish Coalescing Proposal](https://github.com/tc39/proposal-nullish-coalescing)
|
|
- [TypeScript 3.7 Release Notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html)
|
|
- [MDN: Optional Chaining](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining)
|
|
- [MDN: Nullish Coalescing](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing)
|
|
- [Implementation in TeacherStudentsPage](../apps/frontend/src/apps/teacher/pages/TeacherStudents.tsx)
|
|
|
|
---
|
|
|
|
**Versión:** 1.0.0
|
|
**Última actualización:** 2025-11-24
|
|
**Estado:** ✅ Aceptado e Implementado
|
|
**Proyecto:** GAMILIT - Sistema de Gamificación Educativa
|