Clases en TypeScript

Lectura previa: Clases (MDN) ↗

TypeScript ofrece soporte completo para la palabra clave class introducida en ES2015.

Al igual que con otras características del lenguaje JavaScript, TypeScript agrega anotaciones de tipo y otras sintaxis para permitirte expresar relaciones entre clases y otros tipos.

Miembros de clases

Aquí está la clase más básica, una vacía:

Prueba este código ↗

class Point {}

Esta clase aún no es muy útil, así que comencemos a agregar algunos miembros.

Campos

Una declaración de campo crea una propiedad pública editable en una clase:

Prueba este código ↗

class Point {
  x: number;
  y: number;
}
 
const pt = new Point();
pt.x = 0;
pt.y = 0;

Al igual que con otras ubicaciones, la anotación de tipo es opcional, pero será implícitamente any si no se especifica.

Los campos también pueden tener inicializadores; estos se ejecutarán automáticamente cuando se cree una instancia de la clase:

Prueba este código ↗

class Point {
  x = 0;
  y = 0;
}
 
const pt = new Point();
// Imprime 0, 0
console.log(`${pt.x}, ${pt.y}`);

Al igual que con const, let y var, el inicializador de una propiedad de clase se usará para inferir su tipo:

Prueba este código ↗

const pt = new Point();
pt.x = "0";
Error generado
Type 'string' is not assignable to type 'number'.

La opción --strictPropertyInitialization

La configuración strictPropertyInitialization controla si los campos de clase deben inicializarse en el constructor.

Prueba este código ↗

class BadGreeter {
  name: string;
}
Error generado
Property 'name' has no initializer and is not definitely assigned in the constructor.

Prueba este código ↗

class GoodGreeter {
  name: string;
 
  constructor() {
    this.name = "hello";
  }
}

Ten en cuenta que el campo debe inicializarse en el propio constructor. TypeScript no analiza los métodos que invocas desde el constructor para detectar inicializaciones, porque una clase derivada podría sobrescribir esos métodos y no inicializar los miembros.

Si tienes la intención de inicializar definitivamente un campo a través de medios distintos al constructor (por ejemplo, tal vez una biblioteca externa esté completando parte de tu clase por ti), puedes usar el operador de aserción de asignación, !:

Prueba este código ↗

class OKGreeter {
  // No inicializado, pero sin error
  name!: string;
}

El prefijo readonly

Los campos pueden tener el prefijo readonly. Esto evita asignaciones al campo fuera del constructor.

Prueba este código ↗

class Greeter {
  readonly name: string = "world";
 
  constructor(otherName?: string) {
    if (otherName !== undefined) {
      this.name = otherName;
    }
  }
 
  err() {
    this.name = "not ok";
  }
}
const g = new Greeter();
g.name = "also not ok";
Error generado
Cannot assign to 'name' because it is a read-only property.

Constructores

Lectura previa: Constructor (MDN) ↗

Los constructores de clases son muy similares a las funciones. Puede agregar parámetros con anotaciones de tipo, valores predeterminados y sobrecargas:

Prueba este código ↗

class Point {
  x: number;
  y: number;
 
  // Firma normal con valores por defecto.
  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
}

Prueba este código ↗

class Point {
  // Sobrecargas
  constructor(x: number, y: string);
  constructor(s: string);
  constructor(xs: any, y?: any) {
    // TBD
  }
}

Solo existen algunas diferencias entre las firmas de constructores de clases y las firmas de funciones:

  • Los constructores no pueden tener parámetros de tipo; estos pertenecen a la declaración de clase externa, sobre la cual aprenderemos más adelante.
  • Los constructores no pueden tener anotaciones de tipo de retorno: el tipo de instancia de clase siempre es lo que se devuelve

Llamadas a super

Al igual que en JavaScript, si tienes una clase base, necesitarás llamar a super(); en el cuerpo de tu constructor antes de usar cualquier miembro de this.:

Prueba este código ↗

class Base {
  k = 4;
}
 
class Derived extends Base {
  constructor() {
    // Imprime un valor incorrecto en ES5; lanza una excepción en ES6
    console.log(this.k);
    super();
  }
}
Error generado
'super' must be called before accessing 'this' in the constructor of a derived class.

Olvidarse de llamar a super es un error fácil de cometer en JavaScript, pero TypeScript te dirá cuándo es necesario.

Métodos

Lectura previa: Definiciones de métodos ↗

Una propiedad de función en una clase se llama método. Los métodos pueden usar todas las anotaciones del mismo tipo como funciones y constructores:

Prueba este código ↗

class Point {
  x = 10;
  y = 10;
 
  scale(n: number): void {
    this.x *= n;
    this.y *= n;
  }
}

Aparte de las anotaciones de tipo estándar, TypeScript no agrega nada más nuevo a los métodos.

Ten en cuenta que dentro del cuerpo de un método, todavía es obligatorio acceder a los campos y otros métodos a través de this. Un nombre no calificado en el cuerpo de un método siempre se referirá a algo en el ámbito adjunto:

Prueba este código ↗

let x: number = 0;
 
class C {
  x: string = "hello";
 
  m() {
    // Esto intenta modificar 'x' de la línea 1, no la propiedad de clase
    x = "world";
  }
}
Error generado
Type 'string' is not assignable to type 'number'.

Getters / Setters

Las clases también pueden tener accesorios:

Prueba este código ↗

class C {
  _length = 0;
  get length() {
    return this._length;
  }
  set length(value) {
    this._length = value;
  }
}

Ten en cuenta que un par get/set respaldado por campos sin lógica adicional rara vez es útil en JavaScript. Está bien exponer campos públicos si no necesitas agregar lógica adicional durante las operaciones get/set.

TypeScript tiene algunas reglas de inferencia especiales para los descriptores de acceso:

  • Si get existe pero no set, la propiedad es automáticamente readonly
  • Si no se especifica el tipo de parámetro de establecimiento (setter), se infiere del tipo de retorno del getter.
  • Los getters y setters deben tener la misma visibilidad de miembros

Desde TypeScript 4.3 ↗, es posible tener descriptores de acceso con diferentes tipos para obtener y asignar.

Prueba este código ↗

class Thing {
  _size = 0;
 
  get size(): number {
    return this._size;
  }
 
  set size(value: string | number | boolean) {
    let num = Number(value);
 
    // No permite NaN, Infinity, etc
 
    if (!Number.isFinite(num)) {
      this._size = 0;
      return;
    }
 
    this._size = num;
  }
}

Firmas de índices

Las clases pueden declarar firmas de índice; estos funcionan igual que Firmas de índice para otros tipos de objetos:

Prueba este código ↗

class MyClass {
  [s: string]: boolean | ((s: string) => boolean);
 
  check(s: string) {
    return this[s] as boolean;
  }
}

Debido a que el tipo de firma de índice también debe capturar los tipos de métodos, no es fácil utilizar estos tipos de manera útil. Generalmente es mejor almacenar los datos indexados en otro lugar en lugar de en la instancia de la clase misma.

Herencia de clase

Al igual que otros lenguajes con características orientadas a objetos, las clases en JavaScript pueden heredar de las clases base.

Cláusulas implements

Puedes usar una cláusula de implements para verificar que una clase satisface una interface particular. Se emitirá un error si una clase no logra implementarla correctamente:

Prueba este código ↗

interface Pingable {
  ping(): void;
}
 
class Sonar implements Pingable {
  ping() {
    console.log("ping!");
  }
}
 
class Ball implements Pingable {
  pong() {
    console.log("pong!");
  }
}
Error generado
Class 'Ball' incorrectly implements interface 'Pingable'.
  Property 'ping' is missing in type 'Ball' but required in type 'Pingable'.

Las clases también pueden implementar múltiples interfaces, p. class C implements A, B {.

Precauciones

Es importante entender que una cláusula implements es solo una verificación de que la clase puede ser tratada como el tipo de interfaz. No cambia el tipo de clase ni sus métodos en absoluto. Una fuente común de error es asumir que una cláusula implements cambiará el tipo de clase - ¡no es así!

Prueba este código ↗

interface Checkable {
  check(name: string): boolean;
}
 
class NameChecker implements Checkable {
  check(s) {
    // No hay errores aquí.
    return s.toLowerCase() === "ok";
                 
  }
}
Error generado
Parameter 's' implicitly has an 'any' type.

En este ejemplo, quizás esperábamos que el tipo de s estuviera influenciado por el parámetro name: string de check. No lo es: las cláusulas implements no cambian la forma en que se verifica el cuerpo de la clase ni se infiere su tipo.

De manera similar, implementar una interfaz con una propiedad opcional no crea esa propiedad:

Prueba este código ↗

interface A {
  x: number;
  y?: number;
}
class C implements A {
  x = 0;
}
const c = new C();
c.y = 10;
Error generado
Property 'y' does not exist on type 'C'.

Cláusulas extends

Lectura previa: la palabra clave extends (MDN) ↗

Las clases pueden “extenderse” desde una clase base. Una clase derivada tiene todas las propiedades y métodos de su clase base y también puede definir miembros adicionales.

Prueba este código ↗

class Animal {
  move() {
    console.log("Moving along!");
  }
}
 
class Dog extends Animal {
  woof(times: number) {
    for (let i = 0; i < times; i++) {
      console.log("woof!");
    }
  }
}
 
const d = new Dog();
// Método de la clase base.
d.move();
// Método de la clase derivada.
d.woof(3);

Sobrescribiendo métodos

Lectura previa: palabra clave super (MDN) ↗

Una clase derivada también puede sobrescribir un campo o propiedad de una clase base. Puedes utilizar la sintaxis super. para acceder a los métodos de la clase base. Ten en cuenta que debido a que las clases de JavaScript son un objeto de búsqueda simple, no existe la noción de un “super campo”.

TypeScript exige que una clase derivada sea siempre un subtipo de su clase base.

Por ejemplo, aquí tienes una forma legal de sobrescribir un método:

Prueba este código ↗

class Base {
  greet() {
    console.log("Hello, world!");
  }
}
 
class Derived extends Base {
  greet(name?: string) {
    if (name === undefined) {
      super.greet();
    } else {
      console.log(`Hello, ${name.toUpperCase()}`);
    }
  }
}
 
const d = new Derived();
d.greet();
d.greet("reader");

Es importante que una clase derivada siga su contrato de clase base. Recuerda que es muy común (¡y siempre legal!) hacer referencia a una instancia de clase derivada a través de una referencia de clase base:

Prueba este código ↗

// Alias la instancia derivada a través de una referencia de clase base
const b: Base = d;
// Ningún problema
b.greet();

¿Qué pasaría si Derived no siguiera el contrato de Base?

Prueba este código ↗

class Base {
  greet() {
    console.log("Hello, world!");
  }
}
 
class Derived extends Base {
  // Hacer que este parámetro sea obligatorio
  greet(name: string) {
    console.log(`Hello, ${name.toUpperCase()}`);
  }
}
Error generado
Property 'greet' in type 'Derived' is not assignable to the same property in base type 'Base'.
  Type '(name: string) => void' is not assignable to type '() => void'.

Si compilamos este código a pesar del error, este ejemplo fallaría:

Prueba este código ↗

const b: Base = new Derived();
// Se bloquea porque el "nombre" no estará definido
b.greet();

Declaraciones de campos type-only

Cuando target >= ES2022 o useDefineForClassFields es true, los campos de clase se inicializan después de que el constructor de la clase madre se completa, sobrescribiendo cualquier valor establecido por la clase madre. Esto puede ser un problema cuando sólo deseas volver a declarar un tipo más preciso para un campo heredado. Para manejar estos casos, puedes escribir declare para indicar a TypeScript que no debería haber ningún efecto de tiempo de ejecución para esta declaración de campo.

Prueba este código ↗

interface Animal {
  dateOfBirth: any;
}
 
interface Dog extends Animal {
  breed: any;
}
 
class AnimalHouse {
  resident: Animal;
  constructor(animal: Animal) {
    this.resident = animal;
  }
}
 
class DogHouse extends AnimalHouse {
  // No emite código JavaScript, 
  // solo garantiza que los tipos sean correctos
  declare resident: Dog;
  constructor(dog: Dog) {
    super(dog);
  }
}

Orden de inicialización

El orden en que se inicializan las clases de JavaScript puede resultar sorprendente en algunos casos. Consideremos este código:

Prueba este código ↗

class Base {
  name = "base";
  constructor() {
    console.log("My name is " + this.name);
  }
}
 
class Derived extends Base {
  name = "derived";
}
 
// Prints "base", not "derived"
const d = new Derived();

¿Qué pasó aquí?

El orden de inicialización de clases, tal como lo define JavaScript, es:

  • Los campos de la clase base están inicializados.
  • Se ejecuta el constructor de la clase base.
  • Los campos de clase derivada se inicializan.
  • Se ejecuta el constructor de la clase derivada.

Esto significa que el constructor de la clase base vio su propio valor para name durante su propio constructor, porque las inicializaciones del campo de la clase derivada aún no se habían ejecutado.

Heredar tipos integrados

Nota: Si no planeas heredar de tipos integrados como Array, Error, Map, etc. o si tu objetivo de compilación está configurado explícitamente en ES6/ES2015 o superior, puedes saltarte esta sección

En ES2015, los constructores que devuelven un objeto sustituyen implícitamente el valor de this por cualquier llamador de super(...). Es necesario que el código constructor generado capture cualquier valor de retorno potencial de super(...) y lo reemplace con this.

Como resultado, es posible que las subclasificaciones Error, Array y otras ya no funcionen como se esperaba. Esto se debe al hecho de que las funciones constructoras para Error, Array y similares usan new.target de ECMAScript 6 para ajustar la cadena del prototipo; sin embargo, no hay forma de garantizar un valor para new.target al invocar un constructor en ECMAScript 5. Otros compiladores de nivel inferior generalmente tienen la misma limitación de forma predeterminada.

Para una subclase como la siguiente:

Prueba este código ↗

class MsgError extends Error {
  constructor(m: string) {
    super(m);
  }
  sayHello() {
    return "hello " + this.message;
  }
}

es posible que encuentres que:

  • Los métodos pueden ser undefined en los objetos devueltos al construir estas subclases, por lo que llamar a sayHello resultará en un error.
  • instanceof se dividirá entre las instancias de la subclase y sus instancias, por lo que (new MsgError()) instanceof MsgError devolverá false.

Como recomendación, puedes ajustar manualmente el prototipo inmediatamente después de cualquier llamada super(...).

Prueba este código ↗

class MsgError extends Error {
  constructor(m: string) {
    super(m);
 
    // Establece el prototipo explícitamente.
    Object.setPrototypeOf(this, MsgError.prototype);
  }
 
  sayHello() {
    return "hello " + this.message;
  }
}

Sin embargo, cualquier subclase de MsgError también tendrá que configurar manualmente el prototipo. Para runtimes que no admiten Object.setPrototypeOf, es posible que puedas utilizar __proto__.

Desafortunadamente, [estas soluciones no funcionarán en Internet Explorer 10 y versiones anteriores ↗](https://msdn.microsoft.com/en-us/library/s4esdbwz(v=vs.94) .aspx). Se pueden copiar manualmente métodos del prototipo a la instancia misma (es decir, MsgError.prototype a this), pero la cadena del prototipo en sí no se puede arreglar.

Visibilidad de miembros

Puedes usar TypeScript para controlar si ciertos métodos o propiedades son visibles para el código fuera de la clase.

public

La visibilidad predeterminada de los miembros de la clase es public. Se puede acceder a un miembro public desde cualquier lugar:

Prueba este código ↗

class Greeter {
  public greet() {
    console.log("hi!");
  }
}
const g = new Greeter();
g.greet();

Debido a que public ya es el modificador de visibilidad predeterminado, nunca necesitas escribirlo en un miembro de la clase, pero puedes elegir hacerlo por razones de estilo/legibilidad.

protected

Los miembros protected solo son visibles para las subclases de la clase en la que están declarados.

Prueba este código ↗

class Greeter {
  public greet() {
    console.log("Hello, " + this.getName());
  }
  protected getName() {
    return "hi";
  }
}
 
class SpecialGreeter extends Greeter {
  public howdy() {
    // Está bien acceder al miembro protegido aquí.
    console.log("Howdy, " + this.getName());
  }
}
const g = new SpecialGreeter();
g.greet(); // OK
g.getName(); // Error
Error generado
Property 'getName' is protected and only accessible within class 'Greeter' and its subclasses.

Exposición de miembros protected

Las clases derivadas deben seguir sus contratos de clase base, pero pueden optar por exponer un subtipo de clase base con más capacidades. Esto incluye hacer que los miembros protected sean public:

Prueba este código ↗

class Base {
  protected m = 10;
}
class Derived extends Base {
  // Sin modificadores, por defecto es 'public'
  m = 15;
}
const d = new Derived();
console.log(d.m); // OK

Ten en cuenta que Derived ya podía leer y escribir libremente m, por lo que esto no altera significativamente la “seguridad” de esta situación. Lo principal a tener en cuenta aquí es que en la clase derivada, debemos tener cuidado de repetir el modificador protected si esta exposición no es intencional.

Acceso protected entre jerarquías

Diferentes lenguajes de programación orientada a objetos no están de acuerdo sobre si es legal acceder a un miembro protected a través de una referencia de clase base:

Prueba este código ↗

class Base {
  protected x: number = 1;
}
class Derived1 extends Base {
  protected x: number = 5;
}
class Derived2 extends Base {
  f1(other: Derived2) {
    other.x = 10;
  }
  f2(other: Derived1) {
    other.x = 10;
  }
}
Error generado
Property 'x' is protected and only accessible within class 'Derived1' and its subclasses.

Java, por ejemplo, considera que esto es legal. Por otro lado, C# y C++ optaron por que este código fuera ilegal.

TypeScript está del lado de C# y C++ aquí, porque acceder a x en Derived2 solo debería ser legal desde las subclases de Derived2, y Derived1 no es una de ellas. Además, si acceder a x a través de una referencia Derived1 es ilegal (¡y ciertamente debería serlo!), entonces acceder a través de una referencia de clase base nunca debería mejorar la situación.

Consulta también ¿Por qué no puedo acceder a un miembro protegido desde una clase derivada? ↗ que explica más del razonamiento de C#.

private

private es como protected, pero no permite el acceso al miembro ni siquiera desde subclases:

Prueba este código ↗

class Base {
  private x = 0;
}
const b = new Base();
// No se puede acceder desde fuera de la clase
console.log(b.x);
Error generado
Property 'x' is private and only accessible within class 'Base'.

Prueba este código ↗

class Derived extends Base {
  showX() {
    // No se puede acceder en subclases
    console.log(this.x);
  }
}
Error generado
Property 'x' is private and only accessible within class 'Base'.

Debido a que los miembros private no son visibles para las clases derivadas, una clase derivada no puede aumentar su visibilidad:

Prueba este código ↗

class Base {
  private x = 0;
}
class Derived extends Base {
  x = 1;
}
Error generado
Class 'Derived' incorrectly extends base class 'Base'.
  Property 'x' is private in type 'Base' but not in type 'Derived'.

Acceso private entre instancias

Diferentes lenguajes de programación orientada a objetos no están de acuerdo sobre si diferentes instancias de la misma clase pueden acceder a los miembros private de cada uno. Mientras que lenguajes como Java, C#, C++, Swift y PHP lo permiten, Ruby no.

TypeScript permite el acceso private entre instancias:

Prueba este código ↗

class A {
  private x = 10;
 
  public sameAs(other: A) {
    // Sin error
    return other.x === this.x;
  }
}

Advertencias

Al igual que otros aspectos del sistema de tipos de TypeScript, private y protectedsolo se aplican durante la verificación de tipos ↗.

Esto significa que las construcciones de tiempo de ejecución de JavaScript como in o la búsqueda de propiedad simple aún pueden acceder a un miembro private o protected:

Prueba este código ↗

class MySafe {
  private secretKey = 12345;
}
// En un archivo JavaScript...
const s = new MySafe();
// Imprimirá 12345
console.log(s.secretKey);

private también permite el acceso usando notación entre corchetes durante la verificación de tipos. Esto hace que los campos declarados private sean potencialmente más fáciles de acceder para cosas como tests unitarios, con el inconveniente de que estos campos son ligeramente privados y no imponen estrictamente la privacidad.

Prueba este código ↗

class MySafe {
  private secretKey = 12345;
}
 
const s = new MySafe();
 
// No permitido durante la verificación de tipo
console.log(s.secretKey);
 
// OK
console.log(s["secretKey"]);
Error generado
Property 'secretKey' is private and only accessible within class 'MySafe'.

A diferencia del private de TypeScripts, los campos privados de JavaScript ↗ (#) permanecen privados después de la compilación y no proporcionan las trampillas de escape mencionadas anteriormente, como el acceso a la notación entre corchetes, lo que los hace duros y privados.

Prueba este código ↗

class Dog {
  #barkAmount = 0;
  personality = "happy";
 
  constructor() {}
}

Prueba este código ↗

"use strict";
class Dog {
    #barkAmount = 0;
    personality = "happy";
    constructor() { }
}
 

Al compilar en ES2021 o menor, TypeScript usará WeakMaps en lugar de #.

Prueba este código ↗

"use strict";
var _Dog_barkAmount;
class Dog {
    constructor() {
        _Dog_barkAmount.set(this, 0);
        this.personality = "happy";
    }
}
_Dog_barkAmount = new WeakMap();
 

Si necesitas proteger los valores de tu clase de actores maliciosos, debes usar mecanismos que ofrezcan privacidad estricta en tiempo de ejecución, como closures, WeakMaps o campos privados. Ten en cuenta que estas comprobaciones de privacidad adicionales durante el tiempo de ejecución podrían afectar el rendimiento.

Miembros estáticos

Lectura previa: Miembros estáticos (MDN) ↗

Las clases pueden tener miembros static. Estos miembros no están asociados con una instancia particular de la clase. Se puede acceder a ellos a través del propio objeto constructor de clase:

Prueba este código ↗

class MyClass {
  static x = 0;
  static printX() {
    console.log(MyClass.x);
  }
}
console.log(MyClass.x);
MyClass.printX();

Los miembros estáticos también pueden usar los mismos modificadores de visibilidad public, protected y private:

Prueba este código ↗

class MyClass {
  private static x = 0;
}
console.log(MyClass.x);
Error generado
Property 'x' is private and only accessible within class 'MyClass'.

Los miembros estáticos también se heredan:

Prueba este código ↗

class Base {
  static getGreeting() {
    return "Hello world";
  }
}
class Derived extends Base {
  myGreeting = Derived.getGreeting();
}

Nombres estáticos especiales

Generalmente no es seguro ni posible sobrescribir propiedades del prototipo Function. Debido a que las clases son en sí mismas funciones que se pueden invocar con new, ciertos nombres static no se pueden usar. Las propiedades de funciones como name, length y call no son válidas para definirse como miembros static:

Prueba este código ↗

class S {
  static name = "S!";
}
Error generado
Static property 'name' conflicts with built-in property 'Function.name' of constructor function 'S'.

¿Por qué no hay clases estáticas?

TypeScript (y JavaScript) no tienen una construcción llamada static class de la misma manera que, por ejemplo, C#.

Esas construcciones sólo existen porque esos lenguajes obligan a que todos los datos y funciones estén dentro de una clase; como esa restricción no existe en TypeScript, no es necesaria. Una clase con una sola instancia normalmente se representa simplemente como un objeto normal en JavaScript/TypeScript.

Por ejemplo, no necesitamos una sintaxis de “clase estática” en TypeScript porque un objeto normal (o incluso una función de nivel superior) hará el trabajo igual de bien:

Prueba este código ↗

// Clase "static" innecesaria
class MyStaticClass {
  static doSomething() {}
}
 
// Preferido (alternativa 1)
function doSomething() {}
 
// Preferido (alternativa 2)
const MyHelperObject = {
  dosomething() {},
};

Bloques static en clases

Los bloques estáticos te permiten escribir una secuencia de declaraciones con su propio alcance que pueden acceder a campos privados dentro de la clase que los contiene. Esto significa que podemos escribir código de inicialización con todas las capacidades de escribir declaraciones, sin fugas de variables y con acceso completo a las partes internas de nuestra clase.

Prueba este código ↗

class Foo {
    static #count = 0;
 
    get count() {
        return Foo.#count;
    }
 
    static {
        try {
            const lastInstances = loadLastInstances();
            Foo.#count += lastInstances.length;
        }
        catch {}
    }
}

Clases genéricas

Las clases, al igual que las interfaces, pueden ser genéricas. Cuando se crea una instancia de una clase genérica con new, sus parámetros de tipo se infieren de la misma manera que en una llamada de función:

Prueba este código ↗

class Box<Type> {
  contents: Type;
  constructor(value: Type) {
    this.contents = value;
  }
}
 
const b = new Box("hello!");
     
const b: Box<string>

Las clases pueden usar restricciones genéricas y valores predeterminados de la misma manera que las interfaces.

Parámetros de tipo en miembros estáticos

Este código no es legal y puede que no sea obvio por qué:

Prueba este código ↗

class Box<Type> {
  static defaultValue: Type;
}
Error generado
Static members cannot reference class type parameters.

¡Recuerda que los tipos siempre se borran por completo! En tiempo de ejecución, solo hay un espacio de propiedad Box.defaultValue. Esto significa que configurar Box<string>.defaultValue (si fuera posible) también cambiaría Box<number>.defaultValue - no es bueno. Los miembros static de una clase genérica nunca pueden hacer referencia a los parámetros de tipo de la clase.

this en clases en tiempo de ejecución

Lectura previa:palabra clave this (MDN) ↗

Es importante recordar que TypeScript no cambia el comportamiento de ejecución de JavaScript, y que JavaScript es algo famoso por tener algunos comportamientos de ejecución peculiares.

El manejo de this por parte de JavaScript es realmente inusual:

Prueba este código ↗

class MyClass {
  name = "MyClass";
  getName() {
    return this.name;
  }
}
const c = new MyClass();
const obj = {
  name: "obj",
  getName: c.getName,
};
 
// Imprime "obj", no "MyClass"
console.log(obj.getName());

En pocas palabras, de forma predeterminada, el valor de this dentro de una función depende de cómo se llamó a la función. En este ejemplo, debido a que la función fue llamada a través de la referencia obj, su valor de this era obj en lugar de la instancia de clase.

¡Esto rara vez es lo que quieres que suceda! TypeScript proporciona algunas formas de mitigar o prevenir este tipo de error.

Funciones de flecha

Lectura previa: Funciones de flecha (MDN) ↗

Si tienes una función que a menudo se llama de una manera que pierde su contexto this, puede tener sentido usar una propiedad de función de flecha en lugar de una definición de método:

Prueba este código ↗

class MyClass {
  name = "MyClass";
  getName = () => {
    return this.name;
  };
}
const c = new MyClass();
const g = c.getName;
// Imprime "MyClass" en lugar de fallar
console.log(g());

Esto tiene algunas compensaciones:

  • Se garantiza que el valor this será correcto en tiempo de ejecución, incluso para el código que no se verifica con TypeScript.
  • Esto utilizará más memoria, porque cada instancia de clase tendrá su propia copia de cada función definida de esta manera.
  • No puedes usar super.getName en una clase derivada, porque no hay ninguna entrada en la cadena del prototipo para recuperar el método de la clase base.

Parámetros this

En la definición de un método o función, un parámetro inicial llamado this tiene un significado especial en TypeScript. Estos parámetros se borran durante la compilación:

Prueba este código ↗

// Entrada de TypeScript con el parámetro 'this'
function fn(this: SomeType, x: number) {
  /* ... */
}
// Salida JavaScript
function fn(x) {
  /* ... */
}

TypeScript verifica que llamar a una función con un parámetro this se haga con un contexto correcto. En lugar de usar una función de flecha, podemos agregar un parámetro this a las definiciones de métodos para hacer cumplir estáticamente que el método se llame correctamente:

Prueba este código ↗

class MyClass {
  name = "MyClass";
  getName(this: MyClass) {
    return this.name;
  }
}
const c = new MyClass();
// OK
c.getName();
 
// Error, fallaría
const g = c.getName;
console.log(g());
Error generado
The 'this' context of type 'void' is not assignable to method's 'this' of type 'MyClass'.

Este método hace las compensaciones opuestas al enfoque de la función de flecha:

  • Los callers de JavaScript aún pueden usar el método de clase incorrectamente sin darse cuenta.
  • Sólo se asigna una función por definición de clase, en lugar de una por instancia de clase.
  • Las definiciones de métodos base aún se pueden llamar a través de super.

Tipos this

En las clases, un tipo especial llamado this se refiere dinámicamente al tipo de la clase actual. Veamos cómo es útil esto:

Prueba este código ↗

class Box {
  contents: string = "";
  set(value: string) {
  
(method) Box.set(value: string): this
    this.contents = value;
    return this;
  }
}

Aquí, TypeScript infirió que el tipo de retorno de set era this, en lugar de Box. Ahora hagamos una subclase de Box:

Prueba este código ↗

class ClearableBox extends Box {
  clear() {
    this.contents = "";
  }
}
 
const a = new ClearableBox();
const b = a.set("hello");
     
const b: ClearableBox

También puedes usar this en una anotación de tipo de parámetro:

Prueba este código ↗

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}

Esto es diferente a escribir other: Box: si tienes una clase derivada, su método sameAs ahora solo aceptará otras instancias de esa misma clase derivada:

Prueba este código ↗

class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}
 
class DerivedBox extends Box {
  otherContent: string = "?";
}
 
const base = new Box();
const derived = new DerivedBox();
derived.sameAs(base);
Error generado
Argument of type 'Box' is not assignable to parameter of type 'DerivedBox'.
  Property 'otherContent' is missing in type 'Box' but required in type 'DerivedBox'.

Protecciones de tipo basadas en this

Puedes usar this is Type en la posición de retorno para métodos en clases e interfaces. Cuando se mezcla con un tipo de estrechamiento (por ejemplo, declaraciones “if”), el tipo del objeto de destino se limitará al Type especificado.

Prueba este código ↗

class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep;
  }
  isDirectory(): this is Directory {
    return this instanceof Directory;
  }
  isNetworked(): this is Networked & this {
    return this.networked;
  }
  constructor(public path: string, private networked: boolean) {}
}
 
class FileRep extends FileSystemObject {
  constructor(path: string, public content: string) {
    super(path, false);
  }
}
 
class Directory extends FileSystemObject {
  children: FileSystemObject[];
}
 
interface Networked {
  host: string;
}
 
const fso: FileSystemObject = new FileRep("foo/bar.txt", "foo");
 
if (fso.isFile()) {
  fso.content;
  
const fso: FileRep
} else if (fso.isDirectory()) {
  fso.children;
  
const fso: Directory
} else if (fso.isNetworked()) {
  fso.host;
  
const fso: Networked & FileSystemObject
}

Un caso de uso común para una protección de tipos basada en esto es permitir la validación diferida (lazy) de un campo en particular. Por ejemplo, este caso elimina un undefined del valor contenido dentro del cuadro cuando se ha verificado que hasValue es verdadero:

Prueba este código ↗

class Box<T> {
  value?: T;
 
  hasValue(): this is { value: T } {
    return this.value !== undefined;
  }
}
 
const box = new Box();
box.value = "Gameboy";
 
box.value;
     
(property) Box<unknown>.value?: unknown
 
if (box.hasValue()) {
  box.value;
       
(property) value: unknown
}

Propiedades de parámetros

TypeScript ofrece una sintaxis especial para convertir un parámetro de constructor en una propiedad de clase con el mismo nombre y valor. Estas se denominan propiedades de parámetros y se crean anteponiendo un argumento de constructor con uno de los modificadores de visibilidad public, private, pretected o readonly. El campo resultante obtiene esos modificadores:

Prueba este código ↗

class Params {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // El cuerpo no es necesario.
  }
}
const a = new Params(1, 2, 3);
console.log(a.x);
             
(property) Params.x: number
console.log(a.z);
Error generado
Property 'z' is private and only accessible within class 'Params'.

Expresiones de clase

Lectura previa: Expresiones de clase (MDN) ↗

Las expresiones de clase son muy similares a las declaraciones de clase. La única diferencia real es que las expresiones de clase no necesitan un nombre, aunque podemos referirnos a ellas mediante cualquier identificador al que terminaron vinculadas:

Prueba este código ↗

const someClass = class<Type> {
  content: Type;
  constructor(value: Type) {
    this.content = value;
  }
};
 
const m = new someClass("Hello, world");
     
const m: someClass<string>

Firmas de constructor

Las clases de JavaScript se instancian con el operador new. Dado el tipo de una clase en sí, el tipo de utilidad InstanceType ↗ modela esta operación.

Prueba este código ↗

class Point {
  createdAt: number;
  x: number;
  y: number
  constructor(x: number, y: number) {
    this.createdAt = Date.now()
    this.x = x;
    this.y = y;
  }
}
type PointInstance = InstanceType<typeof Point>
 
function moveRight(point: PointInstance) {
  point.x += 5;
}
 
const point = new Point(3, 4);
moveRight(point);
point.x; // => 8

Clases y miembros abstract

Las clases, métodos y campos en TypeScript pueden ser abstractos.

Un método abstracto o campo abstracto es aquel al que no se le ha proporcionado una implementación. Estos miembros deben existir dentro de una clase abstracta, de la que no se puede crear una instancia directamente.

La función de las clases abstractas es servir como clase base para las subclases que implementan todos los miembros abstractos. Cuando una clase no tiene miembros abstractos, se dice que es concreta.

Veamos un ejemplo:

Prueba este código ↗

abstract class Base {
  abstract getName(): string;
 
  printName() {
    console.log("Hello, " + this.getName());
  }
}
 
const b = new Base();
Error generado
Cannot create an instance of an abstract class.

No podemos crear una instancia de Base con new porque es abstracta. En lugar de ello, necesitamos crear una clase derivada e implementar los miembros abstractos:

Prueba este código ↗

class Derived extends Base {
  getName() {
    return "world";
  }
}
 
const d = new Derived();
d.printName();

Observa que si nos olvidamos de implementar los miembros abstractos de la clase base, obtendremos un error:

Prueba este código ↗

class Derived extends Base {
  // olvidé hacer algo
}
Error generado
Non-abstract class 'Derived' does not implement inherited abstract member 'getName' from class 'Base'.

Firmas de construcciones abstractas

A veces quierrás aceptar alguna función constructora de clase que produce una instancia de una clase que deriva de alguna clase abstracta.

Por ejemplo, es posible que desees escribir este código:

Prueba este código ↗

function greet(ctor: typeof Base) {
  const instance = new ctor();
  instance.printName();
}
Error generado
Cannot create an instance of an abstract class.

TypeScript te dice correctamente que estás intentando crear una instancia de una clase abstracta. Después de todo, dada la definición de greet, es perfectamente legal escribir este código, que terminaría construyendo una clase abstracta:

Prueba este código ↗

// Mal!
greet(Base);

En lugar de eso, quierrás escribir una función que acepte algo con una firma de constructor:

Prueba este código ↗

function greet(ctor: new () => Base) {
  const instance = new ctor();
  instance.printName();
}
greet(Derived);
greet(Base);
Error generado
Argument of type 'typeof Base' is not assignable to parameter of type 'new () => Base'.
  Cannot assign an abstract constructor type to a non-abstract constructor type.

Ahora TypeScript te informa correctamente qué funciones constructoras de clase se pueden invocar: Derived puede porque es concreta, pero Base no.

Relaciones Entre Clases

En la mayoría de los casos, las clases en TypeScript se comparan estructuralmente, al igual que otros tipos.

Por ejemplo, estas dos clases se pueden usar en lugar de la otra porque son idénticas:

Prueba este código ↗

class Point1 {
  x = 0;
  y = 0;
}
 
class Point2 {
  x = 0;
  y = 0;
}
 
// OK
const p: Point1 = new Point2();

De manera similar, las relaciones de subtipo entre clases existen incluso si no hay una herencia explícita:

Prueba este código ↗

class Person {
  name: string;
  age: number;
}
 
class Employee {
  name: string;
  age: number;
  salary: number;
}
 
// OK
const p: Person = new Employee();

Esto suena sencillo, pero hay algunos casos que parecen más extraños que otros.

Las clases vacías no tienen miembros. En un sistema de tipos estructurales, un tipo sin miembros es generalmente un supertipo de cualquier otra cosa. Entonces, si escribes una clase vacía (¡no lo hagas!), se puede usar cualquier cosa en su lugar:

Prueba este código ↗

class Empty {}
 
function fn(x: Empty) {
  // No puedo hacer nada con 'x', así que no lo haré.
}
 
// ¡Todo bien!
fn(window);
fn({});
fn(fn);
Última actualización