wandres.dev
EXTENSIONES · añadir sin tocar

Diseñar con extensiones: cuándo sí y cuándo es un tipo que falta

Una extensión brilla cuando adapta una API ajena sin envolverla y se degrada cuando sustituye a una abstracción que nadie ha creado. Las cuatro preguntas que deciden, los síntomas de la proliferación y qué hacer con el montón de extensiones sobre String que ya tienes.

⏱ 20 min

Las cuatro lecciones anteriores describen una herramienta con un mecanismo simple y unos límites nítidos: función estática, resolución léxica, sin estado, sin polimorfismo. Esta trata del único problema que queda, que no es técnico sino de juicio. Las extensiones son tan cómodas de escribir que se escriben sin pensar, y un proyecto de cierta edad suele acumular decenas de operaciones colgadas de String, de Int y de List que en conjunto forman un lenguaje de dominio no declarado, sin tipos, sin invariantes y sin nadie que lo mantenga. El síntoma no es que cada extensión sea mala. Cada una, aislada, parecía la decisión correcta. El problema es que la suma de decisiones correctas puede ser una arquitectura que no eligió nadie, y reconocer el momento en que eso empieza a pasar es lo que distingue a quien diseña de quien acumula.

🎯 Al terminar esta lección sabrás
  • Identificar los tres escenarios donde una extensión es la mejor respuesta disponible.
  • Reconocer los síntomas de que un grupo de extensiones está ocultando un tipo que falta.
  • Aplicar cuatro preguntas de decisión antes de escribir cualquier extensión nueva.
  • Refactorizar un conjunto de extensiones sobre un tipo general hacia una abstracción propia.

Cuándo una extensión mejora una API ajena

El caso fuerte es siempre el mismo: el tipo no es tuyo y la operación depende solo de su contrato público. Ahí la extensión es superior a todas las alternativas, y conviene ver por qué comparándola con lo que haría falta sin ella.

// El tipo viene de una libreria y no se puede tocar
fun java.time.Instant.enZonaLocal(): java.time.LocalDateTime =
    java.time.LocalDateTime.ofInstant(this, java.time.ZoneId.systemDefault())

// Encadena con lo que ya existe, sin envoltorio ni conversion
val texto = instante.enZonaLocal().formatoCorto()

Un envoltorio resolvería lo mismo a costa de una clase nueva, un constructor, un método de desempaquetado y la obligación de convertir en cada frontera con la librería. Una función libre enZonaLocal(instante) resolvería lo mismo a costa de romper el encadenamiento y forzar la lectura de dentro hacia fuera. La extensión no cuesta ni asignación ni conversión y deja la línea legible de izquierda a derecha.

La prueba de que el caso es fuerte está en las tres condiciones que se cumplen a la vez: no puedes tocar el tipo, no necesitas tocarlo porque su API pública basta, y a nadie se le ocurriría querer una versión distinta de la operación para una subclase de Instant, entre otras cosas porque Instant es final. Cuando las tres se dan, no hay ninguna alternativa mejor y la discusión se acaba ahí.

🔌

Adaptar sin envolver

Tipos de librerías y del JDK a los que hay que darles vocabulario de tu dominio. Cero coste en ejecución y cero fricción en las fronteras.

🧩

Mantener la clase pequeña

Operaciones derivadas de tipos que sí controlas, sacadas del cuerpo para que la clase declare solo su estado y sus invariantes. La API esencial se distingue de la conveniente.

🎯

Vocabulario de un ámbito

Extensiones dentro de una clase o de una función que solo tienen sentido en un contexto, que es como se construyen los DSL con receptor y como se limita el autocompletado a lo que procede.

🧪

Ayudantes de test

Constructores de datos de prueba y aserciones específicas, declarados en el conjunto de fuentes de test. Nunca llegan a producción y hacen los tests legibles.

Cuándo esconde una abstracción que falta

El caso débil también tiene una firma reconocible, y son cinco síntomas. Aparecen juntos con mucha frecuencia.

El primero es el receptor demasiado general. Cuando lees fun String.esDniValido(), fun String.formatearComoIban() y fun String.normalizarTelefono() en el mismo archivo, lo que hay delante no son tres operaciones sobre cadenas: son tres tipos de dominio que nadie ha declarado, todos representados por el mismo String que también representa nombres, rutas y mensajes de error. El compilador no puede impedir que pases un teléfono donde se espera un DNI, porque para él ambos son la misma cosa.

El segundo es el grupo con invariante común. Si diez extensiones sobre el mismo tipo comparten una precondición implícita —que la cadena venga ya normalizada, que la lista esté ordenada, que el mapa contenga cierta clave—, ese invariante quiere ser un tipo cuyo constructor lo garantice una vez, en lugar de una suposición repetida diez veces y verificada ninguna.

El tercero es la necesidad de estado. En cuanto una extensión quiere recordar algo entre llamadas, cachear un resultado o llevar una configuración, la respuesta correcta es una clase. Los mapas externos con claves débiles que a veces se usan para simular estado asociado son un mecanismo de último recurso con coste de memoria y trampas de concurrencia.

El cuarto es el deseo de polimorfismo. Si te ves escribiendo una extensión con un when sobre subtipos, o dos extensiones para dos ramas de una jerarquía, lo que pides es un método virtual y el sitio de un método virtual es la interfaz. La segunda lección de este nivel explica exactamente por qué la extensión no va a servir.

El quinto es el problema de descubrimiento. Una extensión existe solo donde se importa, así que quien no sepa que existe no la va a encontrar. Con cinco es irrelevante; con ciento cincuenta repartidas por ocho paquetes, la operación que ya está escrita se reescribe una y otra vez porque nadie la ve.

El diagnóstico rápido cabe en un vistazo al archivo:

// Sintoma: tres tipos de dominio disfrazados del mismo String
fun String.esDniValido(): Boolean = TODO()
fun String.formatearComoIban(): String = TODO()
fun String.normalizarTelefono(): String = TODO()

// Y la firma que lo delata
fun registrar(dni: String, iban: String, telefono: String) = TODO()

Esa última línea admite los tres argumentos en cualquier orden sin que nada se queje. El compilador tiene todo lo necesario para impedirlo y no lo hace porque nadie le ha contado que son cosas distintas.

flowchart TB
s[operacion nueva sobre un tipo] --> q1{es tuyo el tipo}
q1 -->|si| mem[normalmente va como miembro]
q1 -->|no| q2{depende solo de la API publica}
q2 -->|no| env[hace falta un envoltorio o un tipo propio]
q2 -->|si| q3{el receptor representa tu concepto}
q3 -->|no| tipo[falta un tipo de dominio]
q3 -->|si| q4{alguien querria especializarla}
q4 -->|si| iface[hace falta una interfaz]
q4 -->|no| ext[extension justificada]

Las cuatro preguntas antes de escribirla

💡
Un guion de treinta segundos

¿Es mío el tipo? Si lo es, la operación va como miembro salvo que quieras mantener la clase mínima a propósito. ¿Depende solo del contrato público? Si necesita ver dentro, la extensión no puede y estás buscando otra cosa. ¿Representa el receptor lo que la operación trata? Si operas sobre String pero hablas de un número de factura, falta un tipo. ¿Alguien querría cambiar su comportamiento? Si la respuesta es sí, hace falta un miembro open o una interfaz, porque una extensión no se sobrescribe.

La refactorización que resuelve el caso débil casi siempre es la misma, y es más barata de lo que parece:

@JvmInline
value class Telefono private constructor(val e164: String) {
    companion object {
        fun de(bruto: String): Telefono? =
            bruto.filter { it.isDigit() || it == '+' }
                .takeIf { it.length in 8..15 }
                ?.let { Telefono(it) }
    }
    val prefijoPais: String get() = e164.takeWhile { it != ' ' }
    fun enmascarado(): String = e164.dropLast(4) + "****"
}

Las tres extensiones sobre String se convierten en un tipo con un constructor que valida una vez, miembros que ya no necesitan repetir precondiciones, y una firma que impide confundir un teléfono con un DNI. Como value class, además, no hay instancia en el montón en el caso general: la abstracción se paga en tiempo de compilación y desaparece en ejecución. Ese es el intercambio que casi siempre conviene tomar.

Conviene notar que la refactorización no elimina las extensiones, las recoloca. Sigue habiendo sitio para fun Telefono.enWhatsapp(): String en el módulo que hable con ese servicio, y ahora es una extensión ejemplar: el receptor representa exactamente el concepto del que trata la operación, el invariante ya está garantizado por el constructor y el módulo de mensajería no tiene por qué figurar en el tipo del dominio. La diferencia entre la extensión buena y la mala no está en la sintaxis sino en si el sustantivo existía antes de escribirla.

ℹ️
La alternativa moderna: parámetros de contexto

Muchas extensiones existen solo para arrastrar una dependencia hasta el punto de uso, del estilo de fun Pedido.formatear(locale: Locale). Desde que los context parameters se estabilizaron en Kotlin 2.4, ese caso tiene una respuesta mejor: declarar el contexto una vez y dejar que las operaciones lo reciban implícitamente, sin ensuciar ni la firma ni el receptor. Antes de añadir un parámetro a una extensión, pregúntate si lo que falta es un argumento o un ámbito.

⚠️
La extensión también es superficie pública

En una librería, una extensión pública compromete tanto como un método: cambiar su firma, su paquete o el nombre generado para Java rompe a los clientes. Y las extensiones sobre tipos ajenos son lo más frágil que puedes publicar, porque cualquier versión futura de esa librería puede añadir un miembro homónimo que las deje mudas. Publica pocas, con nombres específicos y sobre tipos que controles.

El problema de la expresión y por qué una extensión resuelve la mitad fácil

Hay una razón teórica por la que este juicio es difícil, y tiene nombre desde que Philip Wadler lo bautizó en 1998: el problema de la expresión. Todo diseño de software organiza sus decisiones en una matriz de dos ejes, los casos de datos y las operaciones sobre ellos, y ningún lenguaje tradicional permite crecer libremente por los dos a la vez sin modificar código existente ni perder comprobación estática. La programación orientada a objetos privilegia el eje de los datos: añadir una clase nueva a una jerarquía es gratis, porque la clase trae consigo todas las operaciones, pero añadir una operación exige tocar cada clase de la jerarquía. La programación funcional con tipos algebraicos privilegia el eje contrario: añadir una función que hace su when sobre el tipo suma es gratis, pero añadir un caso obliga a revisar todas las funciones escritas. Las extensiones de Kotlin son, vistas así, un instrumento quirúrgico sobre un solo eje: te permiten añadir operaciones a tipos cerrados y ajenos, sin tocarlos, sin recompilarlos y sin perder ni una comprobación de tipos. Eso es exactamente la mitad que la orientación a objetos hacía cara, y por eso la sensación al usarlas es de haber recuperado algo que faltaba. Pero es solo la mitad, y toda la dificultad de esta lección vive en la otra. Las extensiones no ayudan en absoluto en el eje de los datos: no hacen que un tipo ajeno conforme a tu interfaz, no permiten que una implementación futura especialice tu operación, no participan en ningún despacho. Cuando lo que te falta es una operación, la extensión es la respuesta y es una respuesta excelente. Cuando lo que te falta es un concepto —un tipo que capture un invariante, una interfaz que admita varias implementaciones, un estado que persista— la extensión te dejará escribir algo que compila y se lee bien, y ese es precisamente el peligro: la solución equivocada no dolerá hasta mucho después, cuando la operación número treinta sobre String deba comportarse distinto según qué representa esa cadena y descubras que esa información nunca estuvo en el sistema de tipos. De ahí sale el criterio con el que cerrar el nivel, y es un criterio de una sola frase: una extensión debe añadir un verbo a un sustantivo que ya existe, nunca inventar el sustantivo. Si al nombrar la función necesitas decir qué es realmente el receptor —un teléfono, un identificador, una ruta ya validada, una lista ordenada— entonces lo que estás escribiendo no es una extensión sino la declaración informal de un tipo, y hay una forma mucho mejor de escribir eso. En Kotlin cuesta cinco líneas y una value class, no cobra nada en ejecución y convierte una convención que hay que recordar en una regla que el compilador comprueba. Casi siempre merece la pena, y la señal para hacerlo llega antes de lo que uno cree.

📝
Lo esencial

La extensión es la mejor herramienta cuando el tipo es ajeno, la operación depende solo de su API pública y nadie va a querer especializarla. Es la peor cuando el receptor es un tipo general que hace de sustituto de un concepto tuyo, cuando varias extensiones comparten un invariante implícito, cuando hace falta estado o cuando se busca polimorfismo. Añade verbos a sustantivos existentes; si tienes que inventar el sustantivo, declara un tipo.

⚔️ Audita y refactoriza
  1. Lista todas las extensiones públicas de un módulo tuyo agrupadas por tipo receptor. Anota cuántas cuelgan de String, Int o List.
  2. Pasa las cuatro preguntas a cada una de las diez más usadas y clasifícalas en justificada, dudosa o tipo que falta.
  3. Elige el grupo más numeroso sobre un tipo general y busca el invariante implícito que comparten. Escríbelo en una frase.
  4. Conviértelo en una value class con constructor validador y mueve las extensiones a miembros. Mide qué desaparece: comprobaciones repetidas, comentarios y posibilidades de confundir argumentos.
  5. Baja a internal todas las extensiones que no necesiten ser públicas y comprueba que el módulo sigue compilando. Lo que se rompa te dice dónde estaba la API real.