Tipado avanzado

<< Orientación a objetos Gestión del DOM >>

Operador de opcionalidad '?'

El operador '?', además de para las expresiones ternarias, también se puede utilizar en más ámbitos. Por ejemplo, para indicar que un parámetro en una función es opcional (puede tomar el valor undefined). Otra forma de hacer un parámetro opcional sería darle un valor por defecto.

function saluda(nombre?: string) {
    // El tipo de 'nombre' será: string | undefined
    if(!nombre) {
        console.log("No sé quién eres");
    } else {
        console.log(`Hola ${nombre}`);
    }
}

saluda(); // OK
saluda("Pepe"); // OK

También se puede utilizar para indicar en una clase o interfaz que un atributo puede no tener valor (undefined). Es decir, que es opcional, sin que nos marque ningún error. Debemos tener en cuenta que el tipo del atributo en ese caso, será el que pongamos unido con undefined (ejemplo: number | undefined).

interface Persona {
    nombre: string;
    edad: number;
    numSocio?: number; // Realmente será number | undefined
}

const p: Persona = {
    nombre: "Ana",
    edad: 40
}; // OK
console.log(p.numSocio); // undefined

También se puede utilizar en objetos que pueden ser undefined o null, a la hora de acceder a sus propiedades o métodos, que no de error. En ese caso devuelve el valor undefined o null del objeto directamente sin acceder a la propiedad o método. Este uso también existe en JavaScript.

const a = ["perro", "casa", "árbol", "mesa", "coche"];
const palabra = a.find((p) => p.startsWith("z")); // Devuelve string | undefined

console.log(palabra.toLocaleUpperCase()); // ERROR: 'palabra' is possibly 'undefined'
console.log(palabra?.toLocaleUpperCase()); // Si palabra es undefined, devuelve undefined sin acceder al método

Operador Non-null '!'

El operador '!' en TypeScript sirve para indicar que estamos seguros de que un valor que para TypeScript puede ser undefined o null, tiene valor. Hay que utilizarlo con mucho cuidado, ya que realmente debemos tener esa seguridad o se producirá un error cuando accedamos a alguna de sus propiedades.

En el siguiente ejemplo vemos como cuando una función puede o no devolver un valor (undefined o null), TypeScript tiene en consideración a la hora de tratarlo que la variable puede no tener valor, por lo que nos avisa con un error. Con el operador '!' anulamos dicho error indicando que estamos seguros de que tiene valor.

const a = ["perro", "casa", "árbol", "mesa", "coche"];
const palabra = a.find((p) => p.startsWith("c")); // Devuelve string | undefined

console.log(palabra.toLocaleUpperCase()); // ERROR: 'palabra' is possibly 'undefined'
console.log(palabra!.toLocaleUpperCase()); // Estamos seguros de que no es undefined (Cuidado!)

Este operador se debe utilizar con responsabilidad. Es decir, cuando estamos 100% seguros de que existe un valor asignado (por ejemplo, si lo hemos comprobado antes). En otro caso es mejor siempre contar con la posibilidad de que pueda ser undefined y comprobarlo (o usar el operador ? para que si es undefined no haga nada).

Aquí vemos otro ejemplo donde podemos utilizarlo en una clase para indicar a TypeScript que aunque no vea que le hemos asignado un valor explícito por defecto o en el constructor (es decir, piense que se queda como undefined), estamos seguros de que va a tener valor.

Si por ejemplo, creamos el objeto a partir de un método estático, TypeScript solo comprueba el valor por defecto en los atributos o su asignación en el constructor, por lo que se piensa que van a ser undefined.

class Persona {
    nombre: string; // Property 'nombre' has no initializer and is not definitely assigned in the constructor
    edad: number; // Property 'edad' has no initializer and is not definitely assigned in the constructor

    private constructor() {} // Constructor privado, no se puede invocar fuera

    static crear(nombre: string, edad: number) { // Método constructor estático
        const p = new Persona();
        p.nombre = nombre;
        p.edad = edad;
        return p;
    }

    toString() {
        return `${this.nombre} - ${this.edad}`;
    }
}

const p = Persona.crear("Juan", 23);
console.log(p); // Juan - 23

El código de arriba debería funcionar, por lo que para que el compilador de TypeScript (si así está configurado) no nos marque estos atributo con un error por no haber sido asignados, indicamos con '!' que ambos atributos van a tener un valor seguro. Es importante recordar que este operador solo se debe usar cuando estemos 100% seguros.

class Persona {
    nombre!: string; // OK
    edad!: number; // OK

    // Resto de la clase
}

Tipos genéricos

Los tipos genéricos o generics permite crear funciones y clases que trabajen con diferentes tipos de datos sin tener que especificar el tipo a la hora de definir dichas funciones y clases, sino a la hora de llamar a la función o crear el objeto.

Para definir un tipo genérico en TypeScript, se utiliza la sintaxis <T>, donde T es una variable de tipo que representa el tipo de dato que se pasará como argumento en tiempo de ejecución.

Tipos genéricos en funciones/métodos

Cuando no sabemos el tipo de algún dato con el que va a trabajar una función o método a la hora de declararlo, ya sea el tipo de un parámetro o el valor devuelto, podemos hacer 2 cosas:

  • Tratar este tipo como any (no recomendado) ya que perdemos la ventajas del tipado.
  • Utilizar un tipo genérico y definirlo a la hora de llamar a la función.

El siguiente ejemplo es el de una clase llamada Http, que permite hacer llamadas a servicios web utilizando la API fetch. Para ser capaz de tipar la respuesta del servidor, teniendo en cuenta que según la llamada que hagamos van a ser diferentes datos, podemos usar tipos genéricos en los métodos.

export class Http {
    async ajax<T>(
        method: string,
        url: string,
        headers?: HeadersInit,
        body?: string
    ): Promise<T> {
        const token = localStorage.getItem("token");
        if (token) headers = { ...headers, Authorization: "Bearer " + token };

        const resp = await fetch(url, { method, headers, body });
        if (!resp.ok) throw await resp.json();
        if (resp.status != 204) {
            return await resp.json() as T;
        } else {
            return null as T;
        }
    }

    get<T>(url: string): Promise<T> {
        return this.ajax<T>("GET", url);
    }

    post<T>(url: string, data: any): Promise<T> {
        return this.ajax<T>(
            "POST",
            url,
            {
                "Content-Type": "application/json",
            },
            JSON.stringify(data)
        );
    }

    put<T>(url: string, data: any): Promise<T> {
        return this.ajax<T>(
            "PUT",
            url,
            {
                "Content-Type": "application/json",
            },
            JSON.stringify(data)
        );
    }

    delete<T>(url: string): Promise<T> {
        return this.ajax<T>("DELETE", url);
    }
}

De esta manera, a la hora de hacer una llamada podemos tipar la respuesta del servidor directamente sin tener que hacer un casting de any (tipo por defecto) al tipo que sea posteriormente.

import { Http } from './http.ts';

interface Producto {
    id?: number;
    descripcion: string;
    precio: number;
}

const http = new Http();

http.get<Producto>("https://servidor.com/productos/2").then((p) =>
    console.log(p.descripcion) // p está tipado como Producto (autocompleta, marca errores, etc.)
);

Podemos ir un paso más allá y tipar los datos que le enviamos al servidor en las llamadas POST y PUT. Para eso añadimos un segundo tipo genérico 'U' que se le aplicará al parámetro del método correspondiente.

export class Http {
    //...

    post<T, U>(url: string, data: U): Promise<T> {
        return this.ajax<T>(
            "POST",
            url,
            {
                "Content-Type": "application/json",
            },
            JSON.stringify(data)
        );
    }

    put<T, U>(url: string, data: U): Promise<T> {
        return this.ajax<T>(
            "PUT",
            url,
            {
                "Content-Type": "application/json",
            },
            JSON.stringify(data)
        );
    }

    //...
}

El objetivo de hacer esto es que nos va a obligar a pasarle un objeto del tipo especificado al llamar al método, o al menos que tenga los mismos atributos. Así nos aseguramos que enviamos el dato correcto, y si no, TypeScript nos indicaría un error.

const producto: Producto = {
    descripcion: "Prueba",
    precio: 34,
};

// Ahora además no me dejar pasarle un objeto que no tenga los atributos de Producto
http.post<Producto, Producto>("https://servidor.com/productos", producto).then(
    (p) => console.log(`id generada -> ${p.id}`)
);

Tipos genéricos en clases

Los tipos genéricos se pueden definir a nivel de clase en lugar de método. En este caso, el tipo se especificaría al crear el objeto.

class Almacen<T> {
    #dato: T;

    constructor(dato: T) {
        this.#dato = dato;
    }

    get dato(): T {
        return this.#dato;
    }
}

const almacen1 = new Almacen<string>("manzana");
console.log(almacen1.dato.toLowerCase()); // En este caso es de tipo string (nos autocompleta y marca fallos)

// Si se puede inferir el tipo a partir del parámetro, no hace falta especificar el tipo
// aunque no es mala idea para no equivocarnos y pasarle un dato que no es
const almacen2 = new Almacen(new Date());
console.log(almacen2.dato.getDay()); // En este caso es de tipo Date

Restricciones en los tipos genéricos

Por último, se puede restringir el tipo de dato que le pasemos con la instrucción extends. De esta manera sólo podemos pasar parámetros de ese tipo o derivados.

interface Figura {
    getArea: () => number;
}

class Cuadrado implements Figura {
    #lado: number;

    constructor(lado: number) {
        this.#lado = lado;
    }

    getArea(): number {
        return this.#lado ** 2;
    }
}

class Circulo implements Figura {
    #radio: number;

    constructor(lado: number) {
        this.#radio = lado;
    }

    getArea(): number {
        return this.#radio * Math.PI ** 2;
    }
}

class AlmacenFiguras<T extends Figura> {
    #figuras: T[] = [];

    addFigura(figura: T): void {
        this.#figuras.push(figura);
    }

    sumaAreas(): number {
        // Como T deriva de Figura, me deja llamar a getArea()
        return this.#figuras.reduce((total, f) => total + f.getArea(), 0);
    }
}


const almacen = new AlmacenFiguras<Cuadrado>(); // Este almacén solo admite cuadrados
almacen.addFigura(new Cuadrado(5));
almacen.addFigura(new Cuadrado(6));
console.log(almacen.sumaAreas());  // 61

const almacen2 = new AlmacenFiguras<Circulo>(); // Este almacén solo admite cuadrados
almacen2.addFigura(new Circulo(5));
almacen2.addFigura(new Circulo(6));
console.log(almacen2.sumaAreas());  // 108.56564841198295

Herramientas para el tipado de objetos

Si queremos crear un derivado a partir de un tipo existente, por ejemplo, que todos los campos sean opcionales, u obligatorios, TypeScript tiene una serie de herramientas que nos pueden ayudar a crear dichos tipos derivados. Vamos a ver algunos de ellos.

Vamos a basarnos en esta interfaz para los ejemplos:

export interface Producto {
    id?: number;
    descripcion: string;
    precio: number;
}

Partial

Permite crear un tipo derivado donde todos los atributos son opcionales

const productoParcial: Partial<Producto> = {
    descripcion: "Solo descripción"
};

console.log(productoParcial); // Object { descripcion: "Solo descripción" }

Required

Todo lo contrario a Partial. Todos los campos de la interfaz son obligatorios.

const productoCompleto: Required<Producto> = {
    id: 12,
    descripcion: "Descripción",
    precio: 34
};

console.log(productoCompleto); // Object { id: 12, descripcion: "Descripción", precio: 34 }

Readonly

En este caso los campos son de solo lectura. Solo se les puede dar valor inicial al crear el objeto.

const productoCompleto: Readonly<Producto> = {
    id: 12,
    descripcion: "Descripción",
    precio: 34
};

productoCompleto.descripcion = "Cambio"; // ERROR: Cannot assign to 'descripcion' because it is a read-only property

Puedes consultar más herramientas para crear tipos derivados en la documentación oficial.

<< Orientación a objetos Gestión del DOM >>