wandres.dev
WASM Y JS · los otros backends

Kotlin/JS: `dynamic`, `external` y `@JsExport`

Compilar Kotlin a JavaScript no es traducir un lenguaje tipado a otro sin tipos, sino decidir en qué puntos exactos se renuncia a la comprobación estática y qué se recibe a cambio. Esta lección estudia qué genera el compilador de JavaScript, por qué existe el tipo `dynamic` y qué desactiva realmente, cómo se describe con declaraciones `external` una API que ya vive en el ecosistema, y qué hace `@JsExport` para que el código Kotlin resulte utilizable e incluso tipado desde el otro lado.

⏱ 22 min

Kotlin/JS es el backend más antiguo después de la JVM y también el peor entendido, porque su premisa desafía la intuición: un lenguaje construido sobre la idea de que el compilador debe saberlo todo emite código para una plataforma cuya virtud principal es no saber nada hasta que se ejecuta. La resolución de esa tensión no consiste en imponer el sistema de tipos de Kotlin sobre JavaScript, cosa imposible, sino en trazar con precisión quirúrgica dónde termina el terreno comprobado y empieza el terreno de la promesa. A ese trazado sirven tres mecanismos que conviene no confundir porque resuelven problemas distintos: dynamic renuncia deliberadamente a la comprobación para un valor concreto, external afirma sin demostrar que algo existe fuera con una forma determinada, y @JsExport publica hacia afuera lo que se escribió dentro. Un objeto Kotlin compilado a JavaScript es un objeto JavaScript corriente, y esa identidad de representación es lo que hace barata la travesía y también lo que la hace peligrosa si uno se descuida.

🎯 Al terminar esta lección sabrás
  • Describir qué genera el compilador de Kotlin/JS y cómo el sistema de módulos elegido afecta a la forma del resultado.
  • Usar dynamic sabiendo exactamente qué comprobaciones desactiva y hasta dónde se propaga.
  • Escribir declaraciones external correctas y reconocer que son una afirmación no verificada.
  • Publicar una API con @JsExport y @JsName, anticipando la deformación de nombres y los tipos que no cruzan.

Qué genera el compilador

El compilador produce clases, funciones y propiedades de JavaScript perfectamente normales. No hay máquina virtual intermedia ni capa de emulación de objetos: una clase Kotlin es una clase de JavaScript, y una propiedad con acceso personalizado es un par de funciones de lectura y escritura declaradas en el prototipo. La biblioteca estándar viaja como código JavaScript sometido al mismo proceso de eliminación de código muerto que el resto del programa.

El sistema de módulos elegido cambia la forma de lo publicado más de lo que uno esperaría. Con los formatos heredados la estructura de paquetes se conserva y hay que usar nombres cualificados desde el otro lado; con módulos ES la información de paquete se descarta a propósito, para reducir el tamaño del paquete final y ajustarse a lo que el ecosistema espera, de modo que las declaraciones aparecen como exportaciones nombradas.

// Con modulos ES
import { procesarPedido } from 'mimodulo';
procesarPedido(pedido);

Hay un detalle que sorprende a todo el mundo la primera vez: el compilador deforma los nombres de las funciones para dar cabida a las sobrecargas, que JavaScript no tiene. Una función sobrecargada aparecerá al otro lado con un sufijo derivado de su firma, ilegible e inestable entre versiones. La anotación @JsName sirve para fijar el nombre generado y es obligatoria en la práctica siempre que se exponga una sobrecarga.

💡
Las declaraciones `external` no se deforman

La deformación de nombres solo afecta a lo que el compilador genera. Las declaraciones external conservan su nombre tal cual, igual que las funciones que sobrescriben miembros heredados de una clase externa. Esto es lo que permite describir una biblioteca ajena sin salpicar el código de anotaciones.

dynamic, o la renuncia deliberada

El tipo dynamic desactiva la comprobación estática para el valor que lo lleva. Sobre un valor dinámico se puede invocar cualquier método, leer cualquier propiedad y pasarle cualquier argumento: el compilador acepta la expresión sin objeciones y la traduce de forma literal, dejando que sea el motor quien decida en ejecución si aquello existía.

fun leerPerfil(usuario: dynamic): String {
    // Nada de esto se comprueba en tiempo de compilacion
    val nombre = usuario.perfil.nombre
    usuario.perfil.marcarVisitado(Date())
    return nombre as String
}

Dos propiedades del mecanismo merecen atención. La primera es que el resultado de casi cualquier operación sobre un valor dinámico vuelve a ser dinámico, con lo cual la renuncia se propaga corriente abajo y una sola variable mal contenida puede acabar destiñendo sobre módulos enteros. La segunda es que dynamic no puede usarse como supertipo, lo que impide construir jerarquías sobre él y limita el daño estructural que puede causar.

Hay una tercera propiedad, más sutil, que explica por qué este mecanismo es a la vez tan cómodo y tan traicionero: la conversión con as sobre un valor dinámico no siempre comprueba lo que uno cree. El compilador emite verificaciones para los tipos que sabe distinguir en ejecución, pero JavaScript no conserva información suficiente para separar unos tipos numéricos de otros, de modo que una conversión entre ellos puede tener éxito sobre un valor que no le corresponde y el error emergerá más tarde, lejos del punto donde se cometió.

La disciplina que funciona consiste en tratar lo dinámico como se trata un puerto de entrada sin validar: se admite en el borde, se convierte en la primera línea a un tipo propio y no se propaga ni una función más adentro.

data class Perfil(val nombre: String, val edad: Int)

fun adaptar(bruto: dynamic): Perfil = Perfil(
    nombre = bruto.nombre as String,
    edad = (bruto.edad as Number).toInt(),
)
📝
`dynamic` no existe en Kotlin/Wasm

Es la diferencia más citada entre ambos backends de web y no es un olvido: en Wasm los objetos ajenos no comparten representación con los propios, así que no hay nada sobre lo que aplicar una llamada sin resolver. El sustituto es JsAny acompañado de fragmentos js que hagan explícita la operación no tipada. Todo código que dependa de dynamic es, por definición, código que habrá que reescribir si algún día se migra el objetivo.

external: describir lo que ya existe

Una declaración external es una promesa sin cuerpo: le dice al compilador que en el entorno hay algo con ese nombre y esa forma, y a partir de ahí el sistema de tipos trabaja con normalidad sobre una realidad que nadie ha comprobado. Es el mecanismo con el que se describe una biblioteca ajena para poder usarla con comprobación de tipos en lugar de con dynamic.

external interface Opciones {
    var reintentos: Int
    var tiempoLimite: Int
}

external class Cliente(opciones: Opciones) {
    fun enviar(ruta: String, cuerpo: String): Promise<String>
}

external val version: String

La anotación que indica el módulo de procedencia es lo que conecta la declaración con la dependencia instalada, y sin ella el compilador supone que el nombre vive en el ámbito global.

@file:JsModule("@stripe/stripe-js")

external fun loadStripe(clave: String): Promise<Stripe>

external interface Stripe {
    fun elements(opciones: Opciones): Elements
}

Las interfaces externas son una construcción puramente de compilación: no tienen información de tipos en ejecución, así que no pueden aparecer en comprobaciones con is, no valen como argumento de tipo reificado y una conversión con as hacia ellas siempre tiene éxito, lo cual significa que no comprueba nada. Son útiles precisamente por eso, para dar forma a un objeto de configuración sin generar código, pero conviene saber que no protegen de nada en ejecución.

flowchart TD
A[Codigo JavaScript existente] --> B{Como lo describo}
B -->|Sin tipos| C[dynamic]
B -->|Con tipos afirmados| D[external]
C --> E[Comprobacion en ejecucion por el motor]
D --> F[Comprobacion estatica sobre una promesa]
G[Codigo Kotlin propio] -->|JsExport| H[Visible desde JavaScript]
H --> I[Definiciones de tipos generadas]

@JsExport: publicar hacia afuera

Por omisión nada de lo que se escribe en Kotlin es visible desde JavaScript, y esa reserva es intencionada: permite al compilador eliminar sin miedo todo lo que no se use y renombrar lo que le convenga. La anotación @JsExport marca las declaraciones de nivel superior que deben sobrevivir con su nombre y aparecer en la superficie pública, y puede aplicarse a todo un fichero.

@JsExport
class Carrito {
    private val lineas = mutableListOf<Linea>()
    fun anadir(sku: String, cantidad: Int) { /* ... */ }
    fun total(): Double = lineas.sumOf { it.importe }
}

@JsExport
@JvmInline
value class Correo(val direccion: String) {
    init { require(direccion.contains("@")) { "Correo invalido" } }
}

No todo tipo puede exportarse. Los primitivos y las cadenas cruzan sin más; las colecciones se exponen mediante envoltorios que ofrecen vistas hacia los tipos nativos; Throwable se ve como un error; los tipos enteros sin signo no son exportables. Un valor de clase en línea sí puede exportarse y aparece al otro lado como una clase corriente, lo que permite conservar la validación del constructor a través de la frontera. Para interfaces existe además una anotación que las proyecta como interfaces puras del sistema de tipos, sin residuo en ejecución, a cambio de renunciar a comprobarlas con is.

El compilador puede emitir un fichero de definiciones de tipos a partir de lo exportado, lo que convierte a un módulo Kotlin en una dependencia de primera clase para un equipo que trabaje en TypeScript. Ese fichero es también el mejor documento de revisión disponible: si lo generado resulta ilegible, la API estaba mal diseñada.

Merece la pena insistir en el carácter de contrato que adquiere lo exportado. Mientras una declaración no lleve la anotación, el compilador es libre de renombrarla, de moverla o de eliminarla si nadie la usa; en cuanto la lleva, ese nombre pasa a formar parte de la superficie pública del módulo con las mismas obligaciones de compatibilidad que cualquier API publicada. Exportar de más no es una imprudencia menor: aumenta el tamaño del artefacto porque impide descartar código, y compromete a mantener nombres que quizá solo existían como detalle interno.

💡
Exporta una fachada, no tu modelo de dominio

El patrón que mejor envejece consiste en escribir una capa fina y explícita de fachada, anotada por completo con @JsExport, que traduzca entre el modelo interno y unos tipos pensados para el consumidor. Cuesta un fichero y devuelve tres cosas: libertad para refactorizar el interior sin romper a nadie, un fichero de definiciones de tipos que se lee, y un lugar único donde revisar qué se está prometiendo hacia fuera.

Los tres mecanismos no son alternativas de estilo: son tres formas distintas de repartir la carga de la prueba entre el compilador, el programador y el motor

Conviene mirar dynamic, external y @JsExport no como tres funcionalidades sino como tres respuestas a la misma pregunta epistemológica, que es quién responde cuando algo no encaja. Con dynamic el programador declara que renuncia a saber y traslada íntegramente la verificación al motor, que la hará en el peor momento posible, que es el de la ejecución en casa del usuario; a cambio obtiene la capacidad de hablar con cualquier objeto sin describirlo antes, que es exactamente lo que hace falta cuando la forma del dato solo se conoce en ese instante. Con external el programador afirma una forma y el compilador acepta la afirmación como axioma: la comprobación vuelve a ser estática, pero está anclada en una premisa que nadie verificó, de modo que el error ya no es de tipos sino de correspondencia entre la descripción y la realidad, y se manifiesta como un desajuste silencioso cuando la biblioteca ajena cambia de versión sin avisar. Con @JsExport la dirección se invierte y quien queda expuesto es el propio código: el compilador renuncia a parte de su libertad de renombrar y eliminar para que exista un contrato estable, y ese contrato pasa a ser una superficie que hay que versionar con el mismo cuidado que cualquier API pública. La lectura que trasciende a Kotlin es que un lenguaje tipado que convive con un ecosistema sin tipos no puede elegir entre seguridad y pragmatismo de una vez y para todo el programa, sino que necesita instrumentos que permitan elegir localmente, y la calidad de un diseño se mide por lo pequeña y lo explícita que sea la región donde se renunció a saber. Un programa que use dynamic en tres funciones de adaptación es sólido; uno que lo use por comodidad en toda la capa de datos ha convertido a Kotlin en un JavaScript verboso y ha perdido la única razón por la que valía la pena escribirlo en Kotlin.

⚔️ Traza la frontera de tu módulo
  1. Escribe declaraciones external para una biblioteca de npm pequeña que ya uses y sustituye con ellas todo el dynamic que tuvieras.
  2. Provoca una deformación de nombres exportando dos sobrecargas y corrígela con @JsName. Observa el nombre generado antes del arreglo.
  3. Exporta una clase con @JsExport, genera el fichero de definiciones de tipos y revísalo como si fueras el consumidor. Rediseña lo que resulte ilegible.
  4. Demuestra que una conversión con as hacia una interfaz externa siempre tiene éxito, y escribe la validación que sí protege.
  5. Marca en tu código toda función que reciba o devuelva dynamic y comprueba si alguna está a más de una llamada de distancia del borde del sistema.