Cómo funciona el sistema de tipos en TypeScript

Recientemente tradujimos un artículo fascinante de He Zhenghao, exdesarrollador de Amazon Web Services, donde exploramos la jerarquía de tipos en TypeScript y sus relaciones.
Echa un vistazo a fragmentos de código de TypeScript y trata de determinar por ti mismo si hay errores de concordancia de tipos en cada uno de ellos (si no puedes, no te preocupes, lo explicaremos todo):

// 1. any e unknown
let stringVariable: string = 'string'
let anyVariable: any
let unknownVariable: unknown

anyVariable = stringVariable
unknownVariable = stringVariable
stringVariable = anyVariable
stringVariable = unknownVariable

// 2. `never`
let stringVariable: string = 'string'
let anyVariable: any
let neverVariable: never

neverVariable = stringVariable
neverVariable = anyVariable
anyVariable = neverVariable
stringVariable = neverVariable

// 3. `void`, opción 1
let undefinedVariable: undefined
let voidVariable: void
let unknownVariable: unknown

voidVariable = undefinedVariable
undefinedVariable = voidVariable
voidVariable = unknownVariable

// 4. `void`, opción 2

function fn(cb: () => void): void {
    return cb()
}

fn(() => 'string')

Incluso si ya ha trabajado con TypeScript, buscar errores sin insertar el código en un editor y un compilador puede ser difícil. Para comprender los tipos de TypeScript any, unknown, void y never, es necesario comprender la estructura del sistema de tipos. Veamos eso.
TypeScript: tipos y árbol de jerarquía
Cada tipo en TypeScript tiene su lugar en una jerarquía que se puede representar como una estructura de árbol. Siempre consta de un nodo padre y un nodo hijo. En la jerarquía de tipos, el nodo padre es el supertipo y el nodo hijo es el subtipo.
Uno de los conceptos clave de la programación orientada a objetos es el principio de herencia. Establece una relación "es un" entre una clase hija y una clase padre. Si tomamos la clase padre "Vehículo" y la clase hija "Automóvil", se establece una relación "El automóvil es un vehículo" entre ellas.

Esta regla no funciona en la dirección opuesta: lógicamente, una instancia de la clase padre no es una instancia de la clase hija. "Un vehículo no es un automóvil". Esto es lo que implica el principio de herencia, que también se aplica a la jerarquía de tipos en TypeScript.

Según el principio de sustitución de Liskov, las instancias de la clase "Vehículo" (supertipo) pueden reemplazarse por instancias de la clase hija "Automóvil" (subtipo), y el programa seguirá funcionando correctamente. En otras palabras, si esperamos un cierto comportamiento del tipo "Vehículo", el comportamiento del subtipo "Automóvil" no debe contradecirlo.

En TypeScript, se pueden asignar instancias del subtipo a instancias del supertipo o reemplazar instancias del subtipo por instancias del supertipo, pero no al revés.
Lee también:

Cómo funciona TypeScript y por qué se utiliza.
Tipificación nominal y tipificación estructural de TypeScript
Existen dos enfoques para definir las relaciones entre supertipos y subtipos.

El primer enfoque se utiliza en la mayoría de los lenguajes comunes con tipado estático, como Java, y se llama tipificación nominal. En este caso, debe especificar explícitamente que un tipo es un subtipo de otro tipo, por ejemplo, class Foo extends Bar.

TypeScript utiliza otro enfoque, la tipificación estructural, y no requiere que se especifiquen las relaciones entre tipos en el código. Una instancia del tipo Foo es un subtipo de Bar si contiene todos los miembros de Bar y algunos miembros adicionales.

Para determinar cuál de los tipos es el supertipo y cuál es el subtipo, puede establecer un tipo más estricto. Por ejemplo, el tipo {name: string, age: number} es más estricto que {name: string}, ya que requiere más miembros para cada instancia del primer tipo. Por lo tanto, el tipo {name: string, age: number} es un subtipo del tipo {name: string}.
Dos formas de verificar la posibilidad de asignación o reemplazo
Antes de analizar detenidamente la jerarquía de tipos de TypeScript, aclaremos dos formas de comprobar:
  • 1
    Usando conversión de tipos, puede asignar una variable de un tipo a otra variable de otro tipo y ver si hay errores de concordancia de tipos.
  • 2
    Utilizando la herencia a través de la palabra clave extends, puede crear un nuevo tipo que herede la estructura de un tipo existente:

type *A* = string extends unknown? true : false;  // true
type *B* = unknown extends string? true : false; // false

Nivel superior de la jerarquía
Examinemos el árbol de jerarquía de tipos.

En TypeScript, existen dos tipos que pueden actuar como supertipos de todos los demás tipos: any y unknown.

Aceptan cualquier tipo y, por lo tanto, incluyen todos los demás tipos.
No se muestran todos los tipos de TypeScript en el diagrama. Puede encontrar información sobre todos los tipos admitidos actualmente por TypeScript en este artículo.
Conversión ascendente y descendente de tipos
Existen dos tipos de conversión de tipos: ascendente y descendente.
La asignación de un subtipo a su supertipo se llama conversión ascendente. Según el principio de sustitución de Liskov, la conversión ascendente de tipos es segura, por lo que el compilador permite realizar esta conversión de forma implícita, sin plantear preguntas.

La conversión ascendente de tipos se puede visualizar como subir por el árbol jerárquico, reemplazando tipos más específicos (subtipos) por tipos superiores más generales.

Por ejemplo, cada tipo string es un subtipo de los tipos any y unknown. Por lo tanto, los tipos se pueden asignar de la siguiente manera:

let string: string = 'foo'
let any: any = string // ✅ ⬆️conversión ascendente
let unknown: unknown = string // ✅ ⬆️conversión ascendente

La acción inversa se llama conversión descendente de tipos. Se puede ver como un descenso en el árbol de jerarquía, reemplazando tipos más generales (supertipos) por tipos más específicos (subtipos).

A diferencia de la conversión ascendente, la conversión descendente de tipos no es segura y, en la mayoría de los lenguajes con tipado estricto, no se puede realizar automáticamente. Un ejemplo de conversión descendente sería asignar variables de tipo any y unknown al tipo string:

let any: any
let unknown: unknown
let stringA: string = any // ✅ ⬇️conversión descendente posible teniendo en cuenta las características de 'any'
let stringB: string = unknown // ❌ ⬇️conversión descendente


Si se asigna unknown al tipo string, el compilador TypeScript generará un error de concordancia de tipos, ya que la conversión descendente requiere una anotación explícita del módulo de control de tipos.

Sin embargo, TypeScript aceptará fácilmente la asignación de any al tipo string. Puede parecer contradictorio con la regla mencionada.

Sin embargo, any es una excepción. En TypeScript, este tipo existe como una vía de escape para pasar a JavaScript. Esto refleja el papel dominante de JavaScript como un lenguaje más flexible. TypeScript es un compromiso. Esta excepción no se debió a un error de diseño, sino al hecho de que el lenguaje de ejecución real es JavaScript, no TypeScript.
Nivel inferior de la jerarquía
En la parte inferior del árbol jerárquico se encuentra el tipo never, del cual no se desprenden otras ramas.
El tipo never es completamente opuesto a los tipos de nivel superior, any y unknown, que pueden aceptar cualquier valor. never es un subtipo de todos los demás tipos y no acepta ningún valor, incluyendo los del tipo any.

let any: any
let number: number = 5
let never: never = any // ❌ ⬇️conversión descendente
never = number // ❌ ⬇️conversión descendente
number = never // ✅ ⬆️conversión ascendente

El tipo never debe contener un número ilimitado de tipos y miembros, ya que este tipo se puede asignar a sus supertipos o usar para reemplazarlos. En otras palabras, a todos los demás tipos en el sistema de TypeScript se les aplica el principio de sustitución de Liskov.

Por ejemplo, nuestro programa debería funcionar correctamente si reemplazamos el tipo number o string por never. Dado que never es un subtipo de string y number, no contradice el comportamiento definido por los supertipos.

Desde un punto de vista técnico, esto no es posible. El tipo never en TypeScript es un tipo vacío para el cual no se puede obtener ningún valor en tiempo de ejecución. No se puede hacer nada, por ejemplo, acceder a las propiedades de sus instancias.

Un caso clásico de uso de never puede ser una situación en la que se necesita asignar un tipo de retorno a una función que garantiza que no devuelve nada.

Si una función no devuelve nada, puede deberse a varias razones:
  • La función puede lanzar una excepción en todos los caminos de ejecución del código
  • La función puede contener un bucle infinito que impide que la función se complete hasta que se apague todo el sistema, por ejemplo, un bucle de eventos
Todos estos escenarios son posibles.

function fnThatNeverReturns(): never {
            throw 'La función no devuelve nada'
}

const number: number = fnThatNeverReturns() // ✅ ⬆️conversión ascendente

Puede parecer que el tipo se asigna incorrectamente: después de todo, si never es un tipo vacío, ¿cómo se puede asignar a number? Sin embargo, esto es posible porque el compilador sabe que nuestra función no devuelve nada, por lo que no se le asigna ningún valor a la variable number. Los tipos existen para garantizar la corrección de los datos en tiempo de ejecución. Si no se asigna un valor en ese momento y el compilador lo sabe de antemano, los tipos no desempeñan ningún papel.

Otra forma de crear el tipo never es mediante la intersección de dos tipos incompatibles, por ejemplo, {x: number} y {x: string}.

type Foo = {
    name: string,
    age: number
}
type Bar = {
    name: number,
    age: number
}

type Baz = Foo & Bar

const a: Baz = {age: 12, name:'foo'} // ❌ El tipo 'string' no puede asignarse al tipo 'never'.
El tipo resultante tiene ciertos matices. Si las propiedades incompatibles son propiedades de unión (en términos simples, si sus valores son de tipos literales o uniones de tipos literales), todo el tipo se convierte en never. Esta característica se introdujo en TypeScript 3.9. Puede encontrar información detallada y justificaciones en este artículo.
Tipos en el medio de la jerarquía
Hemos cubierto los tipos en la parte superior e inferior del árbol jerárquico. Entre ellos hay otros tipos estándar y comúnmente utilizados, incluyendo number, string, boolean y tipos compuestos.

Si se entiende bien el sistema de tipos, el funcionamiento de los tipos intermedios será evidente:
  • Se puede asignar el tipo string literal, por ejemplo, let stringLiteral: 'hello' = 'hello', al tipo string (conversión ascendente), pero no al revés (conversión descendente).
  • Se puede asignar una variable que contiene un objeto con más propiedades a un objeto con menos propiedades si los tipos de las propiedades existentes coinciden (conversión ascendente), pero no al revés (conversión descendente).

type UserWithEmail = {name: string, email: string}
type UserWithoutEmail = {name: string}

type *A* = UserWithEmail extends UserWithoutEmail ? true : false // true ✅ conversión ascendente
  • O se puede asignar un objeto no vacío a un objeto vacío:

const emptyObject: {} = {foo: 'bar'} // ✅ conversión ascendente
Vale la pena analizar un tipo específico, void, que a menudo se confunde con el tipo de nivel inferior, never.

En muchos otros lenguajes de programación, como C++, void se usa como el tipo de retorno de una función que significa que la función no devuelve nada. Sin embargo, en TypeScript, el tipo de retorno correcto para una función que no devuelve nada es never.

El tipo void en TypeScript es un superconjunto del tipo undefined. TypeScript permite asignar un valor undefined al tipo void (conversión ascendente), pero no al revés (conversión descendente).
Esto se puede verificar también con la palabra clave extends:

type *A* = undefined extends void ? true : false; // true
type *B* = void extends undefined ? true : false; // false
En JavaScript, el tipo void también se usa como operador para verificar si la expresión que sigue al operador es de tipo undefined, por ejemplo, void 2 === undefined // true.

En TypeScript, el tipo void indica que el ejecutor de la función no garantiza el tipo de valor de retorno, sino que simplemente informa que ese valor no será útil para el llamante. Como resultado, en tiempo de ejecución, una función void podría devolver un valor de otro tipo (que no sea undefined), pero el llamante no podrá usar el valor de retorno.

function fn(cb: () => void): void {
    return cb()
}

fn(() => 'string')
A primera vista, podría parecer que esto infringe el principio de sustitución de Liskov, ya que el tipo string no es un subtipo de void y no puede reemplazar a void. Sin embargo, es importante notar si esta situación afecta al correcto funcionamiento del programa. En resumen, si la función llamante no utiliza el valor de retorno de una función void (que es precisamente el propósito de void), es fácilmente reemplazable por una función que devuelve un valor de otro tipo.

TypeScript aplica un enfoque práctico y complementa las capacidades de manejo de funciones existentes en JavaScript. En JavaScript, las funciones a menudo se reutilizan en nuevos contextos y se ignora el valor de retorno.
Otro uso de void es utilizarlo para anotar this al declarar una función:

function doSomething(this: void, value: string) {
    this // void
}
Como resultado, this no se puede usar dentro de la función.
Situaciones en las que TypeScript prohíbe la conversión ascendente implícita
Hay dos situaciones en las que esto podría suceder, aunque rara vez se presentan en la práctica:

Pasar objetos literales directamente a una función

function fn(obj: {name: string}) {}

fn({name: 'foo', key: 1}) // ❌ El objeto literal solo puede especificar propiedades conocidas, y la propiedad 'key' no existe en el tipo '{ name: string; }'
Asignar objetos literales directamente a variables con tipos explícitos

type UserWithEmail = {name: string, email: string}
type UserWithoutEmail = {name: string}

let userB: UserWithoutEmail = {name: 'foo', email: 'foo@gmail.com'} // ❌ El tipo { name: string; email: string; } no puede asignarse al tipo UserWithoutEmail.
Sigue aprendiendo:

En Códica, hay varias carreras, cursos intensivos y rutas para juniors, mid-level y senior developers. Te permitirán no solo aprender nuevas tecnologías, sino mejorar las competencias existentes
Leer otros artículos de Blog
Lee otros artículos relevantes del mundo de la tecnología y el espíritu empresarial.