Imagina que le pides a alguien que te construya una estantería. Te la entrega. Es bonita. Tiene baldas, tornillos, todo en su sitio. La apoyas contra la pared y se desploma. Los tornillos son de atrezzo. Parecen tornillos, pero son de plástico.
Eso es lo que hace un LLM cuando abusa del sistema de tipos. Te entrega código que compila, que pasa los tests, que tiene la forma correcta. Pero por dentro, donde deberían haber tipos con significado, hay strings. Donde debería haber un estado explícito, hay un nil que significa tres cosas distintas según quién lo lea. Y donde debería haber un enum con dos casos, hay un == "claude" que un día alguien escribirá mal y nadie se enterará hasta producción.
El atajo favorito del modelo: el string universal
He pasado meses trabajando con un agente IA en una app Swift de tamaño medio. El código que genera es limpio, bien estructurado, con buenos nombres. Y sin embargo, hay un patrón que se repite una y otra vez: el modelo evita crear tipos nuevos.
No lo hace por maldad. Lo hace porque crear un tipo nuevo requiere tomar decisiones de diseño: ¿es un enum? ¿Cuántos casos tiene? ¿Dónde vive? ¿Quién lo importa? Un String no requiere ninguna decisión. Encaja en cualquier sitio. Compila siempre.
El resultado es código como este:
func sessions(harness: String? = nil) -> [Session] {
if let harness {
return all.filter { $0.harness == harness }
}
return all
}
// Uso:
let claudeSessions = sessions(harness: "claude")
let codexSessions = sessions(harness: "codex")
Parece correcto. Funciona. Pero hay tres problemas escondidos:
- Nadie te avisa si escribes
"cladue". Un string acepta cualquier cosa. Un enum no. nilsignifica «todos», pero eso no está en ningún tipo. Es una convención que vive en tu cabeza y en un comentario que nadie lee.- Cada función que filtra por harness repite el mismo patrón de
String? = nil. Si mañana añades un tercer harness, tienes que buscar todas las comparaciones de strings desperdigadas por el código.
Usease: el compilador no puede ayudarte porque le has quitado toda la información semántica. Le has dado un String donde había un concepto de dominio.
La versión que debería haber escrito
enum HarnessID: String, Codable {
case claude
case codex
}
enum HarnessFilter {
case all
case specific(HarnessID)
}
func sessions(harness: HarnessFilter = .all) -> [Session] {
switch harness {
case .all:
return all
case .specific(let id):
return all.filter { $0.harnessID == id }
}
}
// Uso:
let claudeSessions = sessions(harness: .specific(.claude))
let allSessions = sessions() // .all por defecto — explícito
Ahora "cladue" no compila. «Todos» no es un nil mágico sino un caso explícito del enum. Y si añades un tercer harness, el compilador te obliga a manejar el nuevo caso en cada switch. Te ha convertido errores de runtime en errores de compilación. Eso es lo que un sistema de tipos debería hacer.
El nil como cajón de sastre
El segundo atajo favorito es usar nil para representar algo que tiene significado propio. El modelo adora los opcionales. Y tiene sentido: un campo opcional es la forma más rápida de añadir un dato sin romper nada. Pero hay una diferencia enorme entre «este valor puede no existir» y «este valor tiene un estado específico que estoy codificando como ausencia».
Ejemplo real:
var calibrationDate: Date? // nil = nunca calibrado
var quotaPercent: Double? // nil = desconocido
var errorMessage: String? // nil = sin error
Tres campos, tres significados distintos de nil. El primero es legítimo: puede que nunca se haya calibrado. El segundo es dudoso: «desconocido» es un estado que merece su propio tipo. El tercero es peligroso: estás usando la ausencia de error para representar éxito, y la presencia de un string para representar fallo. Eso es un Result disfrazado de opcional.
Lo que el compilador ve en los tres casos es lo mismo: Optional<T>. No puede distinguir «legítimamente ausente» de «estoy usando nil como flag booleano». Y si el compilador no lo ve, tú tampoco lo verás cuando leas el código seis meses después.
Por qué el LLM hace esto
No es pereza. Es optimización para el objetivo equivocado.
El modelo está entrenado para generar código que compila y pasa tests. Un String compila siempre. Un nil compila siempre. Un enum nuevo requiere definirlo, importarlo, y actualizar todos los call sites. Desde la perspectiva del modelo, el string tiene menos fricción y el mismo resultado inmediato.
Es exactamente como un junior que usa any en TypeScript. No es que no sepa que hay tipos mejores. Es que any compila y el ticket se cierra. El incentivo está desalineado.
| Lo que optimiza el modelo | Lo que necesitas tú |
|---|---|
| Compilar a la primera | Que el compilador te proteja |
| Pasar los tests existentes | Que los errores nuevos sean imposibles |
| Mínimo cambio en el código | Máxima información semántica |
| Resolver el problema de hoy | No crear problemas para mañana |
Primera defensa: un linter de tipos
Cuando me di cuenta de que el modelo repetía estos patrones, mi primera reacción fue la de siempre: escribir reglas en el CLAUDE.md. «NUNCA usar String donde debería haber un enum«. «NUNCA usar nil como flag».
Ya sabemos cómo acaba eso. El modelo lee la regla, asiente, y a las tres generaciones vuelve a colar un harness: String? = nil. No por rebeldía — porque el string es el camino de menor resistencia y el contexto de 200K tokens ha empujado tu regla fuera del marco de atención.
Así que escribí un linter. Un script de bash que busca patrones sospechosos en el código fuente:
# T4: Comparación literal contra valores que deberían ser enum
fail_if_found "T4" "ERROR" \
'Comparación == "claude" / == "codex" (debe ser == .claude)' \
'==\s*"(claude|codex)"'
# T7: nil como "todos" en parámetros de filtro
fail_if_found "T7" "ERROR" \
'Parámetro harness: String? = nil (debe ser HarnessFilter)' \
'harness:\s*String\?\s*='
# T1: ExpressibleByStringLiteral en tipos de dominio
fail_if_found "T1" "ERROR" \
'ExpressibleByStringLiteral reintroduce stringly-typed' \
'ExpressibleByStringLiteral'
Ocho checks en total. Cada uno busca un patrón concreto con grep. Si lo encuentra, fallo. No hay interpretación, no hay juicio, no hay «bueno, en este caso tiene sentido». Si el patrón aparece, el CI se pone rojo.
Va a ser que no, modelo. Ese == "claude" no pasa.
El linter es deliberadamente tonto. No analiza AST, no entiende contexto, no usa heurísticas sofisticadas. Busca texto. Y eso es una ventaja: es imposible de engañar. El modelo no puede racionalizar una excepción si la detección es puramente textual.
Segunda defensa: una skill de auditoría
El linter pilla los síntomas. Pero los síntomas son consecuencia de un problema más profundo: el modelo no se para a pensar si el tipo que está usando es el correcto.
Para eso creé una skill de auditoría — un fichero Markdown que el agente ejecuta bajo demanda y que revisa sistemáticamente el uso de tipos en cualquier módulo. La skill busca:
- Strings que representan conjuntos finitos. Si un campo solo tiene 3 valores posibles, debería ser un enum, no un
String. - Opcionales que representan estados. Si
nilsignifica algo específico («desconocido», «no aplicable», «todos»), debería ser un caso de enum. - Diccionarios con claves string donde la clave es un concepto de dominio.
[String: Session]debería ser[HarnessID: Session]. setValue(forKey:)y amigos que sortean el sistema de tipos de Core Data.Dictionary(uniqueKeysWithValues:)sin garantía de que las claves sean únicas — crashea en runtime.
La skill no es mágica. Es una checklist que obliga al modelo a recorrer el código con una lupa concreta. La diferencia entre «revisa los tipos» (vago) y «busca campos String que solo admitan N valores conocidos» (específico) es la diferencia entre una auditoría útil y un «todo parece bien».
El antes y el después
Después de aplicar el linter y la auditoría a un módulo que llevaba dos meses en desarrollo:
| Hallazgo | Cantidad | Severidad |
|---|---|---|
Comparaciones == "string literal" |
4 | Error: typo invisible en runtime |
Parámetros String? = nil como filtro |
3 | Error: nil semántico oculto |
Campos String que eran enums disfrazados |
2 | Refactor: pérdida de type safety |
setValue(forKey:) en Core Data |
1 | Error: el compilador no valida el nombre del campo |
Diez hallazgos. Todos introducidos por el modelo. Ninguno detectado por los tests existentes, porque los tests usaban los mismos strings que el código. Ficción validando ficción, otra vez.
Después del refactor, el código tenía 40 líneas más (las definiciones de enum) y cero strings sueltos. Cada comparación pasaba por el compilador. Cada filtro tenía un tipo explícito. Y el siguiente bug de «el modelo escribió "cladue" sin querer» se convirtió en imposible.
Lo que puedes hacer hoy en tu proyecto
No necesitas Swift. No necesitas mi linter. El patrón aplica a cualquier lenguaje con sistema de tipos:
En TypeScript:
// Antes: stringly-typed
function getUsers(role: string = "all") { ... }
getUsers("adnin") // typo, compila, falla en runtime
// Después: type-safe
type Role = "admin" | "viewer" | "billing"
type RoleFilter = Role | "all"
function getUsers(role: RoleFilter = "all") { ... }
getUsers("adnin") // error de compilación
En Python con Enum:
# Antes
def process(status: str | None = None): ...
process("pending") # ¿"pending" o "Pending" o "PENDING"?
# Después
class Status(Enum):
PENDING = "pending"
DONE = "done"
def process(status: Status | None = None): ...
La regla general: si un valor solo puede tomar N opciones conocidas, debe ser un tipo con N casos, no un string con infinitas posibilidades. Si nil significa algo distinto de «ausente», merece su propio nombre.
Y si trabajas con un agente IA, monta un linter que busque los patrones sospechosos. No tiene que ser sofisticado. Un grep con los patrones de tu dominio basta. Lo importante es que se ejecute en CI, que sea automático, y que no dependa de que el modelo haya leído tu README.
El meta-problema
Hay algo irónico en todo esto. Le estamos pidiendo a un modelo de lenguaje — un sistema que opera fundamentalmente con strings — que deje de usar strings donde no debe.
Es como pedirle a un carpintero que deje de usar madera. Claro que puede, pero su instinto le lleva ahí. El string es el tipo nativo del LLM. Todo lo que ve es texto. Todo lo que genera es texto. Cuando tiene que decidir entre un tipo con semántica rica y un string que «funciona», el string gana por defecto.
La solución no es pedirle que cambie. Es meterle mano al problema con herramientas que detecten el patrón automáticamente y lo bloqueen antes de que llegue a main. Un linter tonto gana a un modelo listo si el linter se ejecuta siempre y el modelo se olvida a veces.
Tus tipos son tu documentación viva. Si los degradas a strings, tu código compila pero no comunica nada. Y el siguiente que lo lea — seas tú, un compañero, u otro modelo — tendrá que adivinar qué significaba ese nil.
Haz que adivinar sea innecesario. Haz que el camino incorrecto no compile.
Read this article in English.



