Extender tipos existentes en TypeScript

He hablado en alguna ocasión sobre TypeScript, y reconozco que nunca con demasiado entusiasmo. Sigo pensando que es un lenguaje aburrido, pero también es cierto que resulta práctico y últimamente lo estoy utilizando más profesionalmente. La parte buena de eso es que, como siempre que conoces algo mejor, acabas encontrando algunas cosas que pueden resultar curiosas y, en cierto modo, entretenidas.

TypeScript está diseñado en todo momento teniendo en cuenta que por debajo está Javascript. Y no lo digo sólo porque sea un superconjunto de Javascript; tambien se nota en la forma en que han intentado resolver los problemas derivados con la interoperatibilidad entre ambos lenguajes. No es algo negativo, pasa en casi todos los lenguajes. En Clojure, por ejemplo, existen construcciones orientadas fundamentalmente a interoperar con código Java, y en .NET cuentas con Platform Invoke que se usaba principalmente para interoperar con APIs nativas de C.

Aun así, algunas de las soluciones adoptadas para permitir interactuar de forma “cómoda” con librerías Javascript desde TypeScript pueden resultar un poco chocantes para los que ven TypeScript como un “C# para el browser”.

En este post vamos a aprovechar para ver cómo podemos añadir nuevos métodos a los arrays, pero como eso es algo que puedes averiguar en 30 segundos en Stack Overflow, vamos a intentar entender de paso qué estamos haciendo y no quedarnos la parte de copiar y pegar el código.

La idea es poder coger tipos existentes, posiblemente declarados en librerías externas, y añadirles nuevos métodos o propiedades. Monkey Patching de toda la vida, con todo lo bueno y lo malo que ello implica. Si piensas en TypeScript como un lenguaje de tipado estático parece raro querer hacer esto, pero si piensas en que seguramente necesites acceder desde TypeScript a librerías Javascript donde esto está a la orden del día, como por ejemplo jQuery, parece razonable que haya alguna forma de hacerlo.

Declaration Merging

Para implementar todo esto hay que conocer uno de esos conceptos peculiares de TypeScript que mencionaba al principio del post: la fusión de declaraciones (declaration merging).

En la mayoría de los lenguajes no es posible declarar varios artefactos con el mismo nombre dentro del mismo ámbito. Por ejemplo, en C# no puede haber dos clases con el mismo nombre dentro del mismo espacio de nombres. Tampoco puede haber una clase y un interface con el mismo nombre en el mismo espacio de nombres. O una propiedad y un método dentro de la misma clase.

En TypeScript sí podemos declarar dos “cosas” con el mismo nombre. Podemos declarar un interface y una clase con el mismo nombre. O dos veces el mismo interface. O incluso cosas más exóticas como un interface y un espacio de nombres.

Las reglas que rigen la fusión de declaraciones son un poco complejas pero, por simplificarlas mucho y ciñéndonos al caso en que declaramos dos interfaces con el mismo nombre, lo que realmente se hace es aumentar la definición del interface para que incluya los métodos de ambas de definiciones.

Es el típico concepto que puede resultar extraño la primera vez que lo escuchas, sobre todo porque los ejemplos no acaban de resultar muy claros y parecen demasiado rebuscados, pero cuando lo ves desde la perspectiva de la interoperabilidad con Javascript, cobra mucho más sentido y es fácil hacerse una idea del tipo de cosas que se pueden conseguir.

Con la fusión de declaraciones TypeScript puede aplicar tipado estático a escenarios como el de los plugins de jQuery, que añaden dinámicamente funcionalidad a la librería y para los cuales podemos crear definiciones de tipos que incluyan nuevos métodos en el interface jQuery.

Extendiendo Array<T>

Usando esta idea podemos extender el interface Array<T> definido por defecto en TypeScript para añadir algún método que necesitemos, por ejemplo el típico flatMap.

Lo primero que necesitamos es incluirlo en la definición del interface, y para ello sólo necesitamos definir nuestro propio Array<T> y dejar que la fusión de declaraciones haga el resto:

interface Array<T> {
  flatMap<S>(mapFunc: (value: T, index: number, array: T[]) => S[], thisArg?: any): S[];
}

Teniendo la definición, en algún sitio tendremos que añadir la implementación. Para eso recurrimos a nuestros amigos los prototipos de Javascript:

Array.prototype.flatMap = function(mapFunc: any, thisArg?: any) {
  return [].concat(...this.map(mapFunc, thisArg));
};

Por suerte o por desgracia, prototype está definido siempre con el tipo any, por lo que podemos engancharle lo que queramos. En la parte mala, perdemos todos los tipos genéricos y no nos queda más remedio que recurrir a any para tipar mapFunc, en lugar de mantener el precioso tipado que teníamos en la declaración del interface.

Todo eso puede encontrarse en el mismo fichero o en ficheros diferentes, lo único realmente importante es que la definición del interface Array<T> esté disponible mientras compilamos, y que la extensión de Array.prototype se realice antes de que nuestra aplicación intente usar el método en tiempo de ejecución.

Teniendo esto, podríamos empezar a utilizar flatMap y nuestro editor nos mostraría autocompletado, validación, y todo ese maravilloso tooling que es la razón de ser de TypeScript:

const flags = [
  {country: 'Spain', colors: ['red', 'yellow']},
  {country: 'Nigeria', colors: ['green', 'white']}
];

const colors = flags.flatMap(x => x.colors);
// colors === ['red', 'yellow', 'green', 'white']

Conclusiones

Dejando de lado si es una buena idea o no modificar el prototipo de Array (algo que es, cuanto menos, discutible), siempre es bueno conocer mejor las herramientas que utilizas.

Se nota que detrás de TypeScript hay bastante trabajo para intentar que la interoperabilidad con Javascript sea lo más fluída y potente posible. El declaration merging es una de las características del lenguaje que facilitan esa integración, y aunque a primera vista puede parecer algo que sólo aporta complejidad al lenguaje, lo cierto es que cumple una misión importante.

Si echáis un ojo a las definiciones de tipos de librerías complejas como ReactJS, veréis que además de la fusión de interfaces, la parte de merging entre módulos y namespaces también tiene tu utilidad.


7 comentarios en “Extender tipos existentes en TypeScript

  1. Mientras la declaracion del tipo sea estatica y en tiempo de compilacion en principio daria igual donde este declarado el tipo (si en uno o varios ficheros o modulos). Por ejemplo en haskell puedes declarar instancias de una clase para un tipo en un modulo diferente de donde has declarado el tipo, lo que proporciona la flexibilidad del monkey patching pero mucho mas seguro.
    El problema es el de la coherencia de las definiciones: si declaro dos implementaciones para un codigo que acepta un tipo, ¿cual es la que es efectiva?¿la ultima en el orden de lectura de las definicones?¿la ultima?¿al azar? no se si me convence mucho…
    Siguiendo con el ejemplo de hakell, no puedes definir dos instancias de una clase para un mismo tipo en el mismo espacio de nombres. ¿Como maneja typescript esas incoherencias?

  2. Bueno parece que en principio intentan mantener la coherencia:

    “Currently, classes can not merge with other classes or with variables”

    En los espacios de nombres:
    “This means that after merging, merged members that came from other declarations cannot see non-exported members. ”

    “Non-function members of the interfaces must be unique. The compiler will issue an error if the interfaces both declare a non-function member of the same name.”

  3. Ya sabes que no tengo ni idea de haskell, pero creo que el escenario es distinto.

    Lo que describes parece similar a los protocolos de clojure, ¿no? Tú declaras en un sitio el tipo, en otro el protocolo y otro una (posible) implementación del protocolo.

    En el caso de TypeScript es como si “abrieses” la clase de tipos de haskell y pudieras hacer algo así como:

    -- file1.hs
    class MyClass a where
      m1 :: a -> String
    
    -- file2.hs
    class MyClass a where
      m2 :: a -> String
    
    -- file3.hs
    instance MyClass Float where
      m1 _ = "m1 from file1"
      m2 _ = "m2 from file2"
    

    ¿Eso se puede hacer en haskell?

  4. Una respuesta adecuada creo que sería ésta http://stackoverflow.com/a/1682384/1540749 es decir, si en tus computaciones necesitas que X cosa tenga “algo más”, pues simplemente pide que X implemente ese “algo más”, q tenga q ir con el mismo nombre o no (en mi opinión) es una limitación de TS/JS no de Haskell (xq no lo necesita).

    En todo caso y por no “escurrir el bulto” el problema de expresión (que raramente padecen los patos porque son muy expresivos xD xD) es un problema mucho más interesante (y creo q relacionado con lo que comentáis) q en Haskell se atraganta un poco (a la q hay soluciones claro, pero que no me gustan mucho).

    class MyClass1 a where m1 :: a -> String

    class MyClass2 a where m2 :: a -> String

    instance MyClass1 X where m1 _ = “m1 from file1″

    instance MyClass2 X where m2 _ = “m2 from file2″

    ineed1 :: MyClass1 a => a -> String
    ineed1 = m1

    ineed2 :: MyClass2 a => a -> String
    ineed2 = m2

    ineed12 :: (MyClass1 a, MyClass2 a) => a -> String
    ineed12 x = m1 x ++ m2 x

  5. Por cierto, al problema de expresión al que me refiero es cuando los tipos que se extienden deben compartir implementaciones “cruzadas” que rompen la jerarquía de tipos que fija Haskell.

    Por ejemplo, tenemos cierto contenedor, digamos que un “simulador de vida” y queremos poder sobre él implementar diferentes formas de vida, el problema de expresión en Haskell se produce cuando una de esas nuevas formas debe conocer detalles de implementación (lógicamente no definidos en el simulador) de otra forma de vida.

    class FormaDeVida a where
    peso :: a -> Float
    velocidad :: a -> Float

    – después
    class FormaDeVida a => Ovejuna a where

    – después
    class FormaDeVida a => Lobuna a where

    Pues resulta que las formas de vida Lobunas quieren comerse a las formas de vida Ovejunas ¡pero el simulador no tiene forma de conectarlas!

    Ésto requiere un `instanceof` que no es estático…

  6. Tampoco puedes aumentar modulos en haskell. No queria decir que fuera lo mismo (aunque pueden soluciones para un mismo problema) sino ilustrar como haskell trata el problema de extender las definiciones con tipado estatico y mantener la coherencia de las implementaciones. Si permites que una implementacion machaque a otra (creando varias implementaciones con el prototipo) pierdes gran parte las ventajas de los tipos estaticos (basicamente vuelves al tipado dinamico) y si en la implementacion pierdes los parametros por any pues ya ni te cuento.

    Supongo que son concesiones inevitables si quieres mantener la integracion entre typescript y javascript fluida y no espantar a los devs.

  7. En el caso de TypeScript, la forma de extender los tipos que cuento en el post está pensada para la interoperabilidad con Javascript, o al menos esa sensación me da.

    Me parece mucho más limpio (e idiomático) aprovechar el tipado estructural de TypeScript con los tipos intersección. El resultado es más parecido al de haskell con varias type classes, y más ajustado por tanto a lo que sería el principio de segregación de interfaces.

    Algo así:

    interface MyClass1 {
      method1(): string
    }
    
    interface MyClass2 {
      metod2(): string
    }
    
    function myClass1And2(something : MyClass1 & MyClass2): string {
      return something.method1() + something.method2();
    }
    
    myClass1And2({
      method1: () => 'm1',
      method2: () => 'm2'
    });
    

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos necesarios están marcados *

*

Puedes usar las siguientes etiquetas y atributos HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>