Saltearse al contenido

Exploración del sistema de tipos

El servicio del lenguaje de TypeScript

El servicio del lenguaje de TypeScript, también conocido como tsserver, ofrece distintas funciones, como informes de errores, diagnósticos, compilación al guardar, cambio de nombre, navegación a la definición, listas de finalización, ayuda de firmas y muchas más. Lo utilizan principalmente los entornos de desarrollo integrados (IDE) para ofrecer compatibilidad con IntelliSense. Se integra a la perfección con Visual Studio Code y lo emplean herramientas como Conquer of Completion (Coc).

Los desarrolladores pueden aprovechar una API específica y crear sus propios complementos personalizados para el servicio del lenguaje con el fin de mejorar la experiencia de edición de TypeScript. Esto puede ser especialmente útil para implementar funciones específicas de linting o activar la finalización automática para un lenguaje de plantillas personalizado.

Un ejemplo real de complemento personalizado es “typescript-styled-plugin”, que ofrece informes de errores de sintaxis y compatibilidad con IntelliSense para propiedades CSS en componentes con estilo.

Para obtener más información y consultar guías de inicio rápido, puedes acudir a la wiki oficial de TypeScript en GitHub: https://github.com/microsoft/TypeScript/wiki/

Tipado estructural

TypeScript se basa en un sistema de tipos estructural. Esto significa que la compatibilidad y la equivalencia de los tipos se determinan por su estructura o definición real, y no por su nombre o lugar de declaración, como ocurre en sistemas de tipos nominales como C# o C.

El sistema de tipos estructural de TypeScript se diseñó a partir del funcionamiento en tiempo de ejecución del sistema dinámico de tipado pato de JavaScript.

El siguiente ejemplo es código TypeScript válido. Como puedes observar, “X” e “Y” tienen el mismo miembro “a”, aunque sus declaraciones tengan nombres distintos. Los tipos vienen determinados por sus estructuras y, en este caso, como las estructuras son iguales, son compatibles y válidos.

type X = {
a: string;
};
type Y = {
a: string;
};
const x: X = { a: 'a' };
const y: Y = x; // Válido

Reglas fundamentales de comparación de TypeScript

El proceso de comparación de TypeScript es recursivo y se ejecuta sobre tipos anidados a cualquier nivel.

Un tipo “X” es compatible con “Y” si “Y” tiene, como mínimo, los mismos miembros que “X”.

type X = {
a: string;
};
const y = { a: 'A', b: 'B' }; // Válido, ya que tiene al menos los mismos miembros que X
const r: X = y;

Los parámetros de las funciones se comparan por sus tipos, no por sus nombres:

type X = (a: number) => void;
type Y = (a: number) => void;
let x: X = (j: number) => undefined;
let y: Y = (k: number) => undefined;
y = x; // Válido
x = y; // Válido

Los tipos de retorno de las funciones deben ser iguales:

type X = (a: number) => undefined;
type Y = (a: number) => number;
let x: X = (a: number) => undefined;
let y: Y = (a: number) => 1;
y = x; // No válido
x = y; // No válido

El tipo de retorno de una función de origen debe ser un subtipo del tipo de retorno de una función de destino:

let x = () => ({ a: 'A' });
let y = () => ({ a: 'A', b: 'B' });
x = y; // Válido
y = x; // No válido: falta el miembro b

Se permite descartar parámetros de funciones, ya que es una práctica habitual en JavaScript, por ejemplo al utilizar “Array.prototype.map()”:

[1, 2, 3].map((element, _index, _array) => element + 'x');

Por tanto, las siguientes declaraciones de tipos son totalmente válidas:

type X = (a: number) => undefined;
type Y = (a: number, b: number) => undefined;
let x: X = (a: number) => undefined;
let y: Y = (a: number) => undefined; // Falta el parámetro b
y = x; // Válido

Cualquier parámetro opcional adicional del tipo de origen es válido:

type X = (a: number, b?: number, c?: number) => undefined;
type Y = (a: number) => undefined;
let x: X = a => undefined;
let y: Y = a => undefined;
y = x; // Válido
x = y; //Válido

Los parámetros opcionales del tipo de destino que no tengan parámetros correspondientes en el tipo de origen son válidos y no constituyen un error:

type X = (a: number) => undefined;
type Y = (a: number, b?: number) => undefined;
let x: X = a => undefined;
let y: Y = a => undefined;
y = x; // Válido
x = y; // Válido

El parámetro rest se trata como una serie infinita de parámetros opcionales:

type X = (a: number, ...rest: number[]) => undefined;
let x: X = a => undefined; //válido

Las funciones con sobrecargas son válidas si la firma de sobrecarga es compatible con su firma de implementación:

function x(a: string): void;
function x(a: string, b: number): void;
function x(a: string, b?: number): void {
console.log(a, b);
}
x('a'); // Válido
x('a', 1); // Válido
function y(a: string): void; // No válido, no es compatible con la firma de implementación
function y(a: string, b: number): void;
function y(a: string, b: number): void {
console.log(a, b);
}
y('a');
y('a', 1);

La comparación de parámetros de funciones tiene éxito si los parámetros de origen y destino pueden asignarse a supertipos o subtipos (bivarianza).

// Supertipo
class X {
a: string;
constructor(value: string) {
this.a = value;
}
}
// Subtipo
class Y extends X {}
// Subtipo
class Z extends X {}
type GetA = (x: X) => string;
const getA: GetA = x => x.a;
// La bivarianza acepta supertipos
console.log(getA(new X('x'))); // Válido
console.log(getA(new Y('Y'))); // Válido
console.log(getA(new Z('z'))); // Válido

Los enums pueden compararse con números y asignarse a ellos, y viceversa, pero no es válido comparar valores de enums de tipos distintos.

enum X {
A,
B,
}
enum Y {
A,
B,
C,
}
const xa: number = X.A; // Válido
const ya: Y = 0; // Válido
X.A === Y.A; // No válido

Las instancias de una clase se someten a una comprobación de compatibilidad de sus miembros privados y protegidos:

class X {
public a: string;
constructor(value: string) {
this.a = value;
}
}
class Y {
private a: string;
constructor(value: string) {
this.a = value;
}
}
let x: X = new Y('y'); // No válido

La comprobación de compatibilidad no tiene en cuenta las distintas jerarquías de herencia. Por ejemplo:

class X {
public a: string;
constructor(value: string) {
this.a = value;
}
}
class Y extends X {
public a: string;
constructor(value: string) {
super(value);
this.a = value;
}
}
class Z {
public a: string;
constructor(value: string) {
this.a = value;
}
}
let x: X = new X('x');
let y: Y = new Y('y');
let z: Z = new Z('z');
x === y; // Válido
x === z; // Válido aunque z pertenezca a una jerarquía de herencia diferente

Los genéricos se comparan mediante sus estructuras según el tipo resultante tras aplicar el parámetro genérico; únicamente se compara el resultado final como tipo no genérico.

interface X<T> {
a: T;
}
let x: X<number> = { a: 1 };
let y: X<string> = { a: 'a' };
x === y; // No válido, ya que el argumento de tipo se utiliza en la estructura final
interface X<T> {}
const x: X<number> = 1;
const y: X<string> = 'a';
x === y; // Válido, ya que el argumento de tipo no se utiliza en la estructura final

Cuando no se especifica el argumento de tipo de los genéricos, todos los argumentos sin especificar se tratan como tipos “any”:

type X = <T>(x: T) => T;
type Y = <K>(y: K) => K;
let x: X = x => x;
let y: Y = y => y;
x = y; // Válido

Recuerda:

let a: number = 1;
let b: number = 2;
a = b; // Válido, todo se puede asignar a sí mismo
let c: any;
c = 1; // Válido, todos los tipos se pueden asignar a any
let d: unknown;
d = 1; // Válido, todos los tipos se pueden asignar a unknown
let e: unknown;
let e1: unknown = e; // Válido, unknown solo se puede asignar a sí mismo y a any
let e2: any = e; // Válido
let e3: number = e; // No válido
let f: never;
f = 1; // No válido, nada se puede asignar a never
let g: void;
let g1: any;
g = 1; // No válido, void no se puede asignar a ningún tipo ni recibir asignaciones de ningún tipo excepto any
g = g1; // Válido

Ten en cuenta que, cuando “strictNullChecks” está activado, “null” y “undefined” se tratan de forma similar a “void”; de lo contrario, se comportan de forma parecida a “never”.

Los tipos como conjuntos

En TypeScript, un tipo es un conjunto de valores posibles. Este conjunto también se denomina dominio del tipo. Cada valor de un tipo puede considerarse un elemento de un conjunto. Un tipo establece las restricciones que debe satisfacer cada elemento para considerarse miembro del conjunto. La tarea principal de TypeScript consiste en comprobar y verificar si un conjunto es subconjunto de otro.

TypeScript admite distintos tipos de conjuntos:

Término de conjuntosTypeScriptNotas
Conjunto vacíonever”never” no contiene nada aparte de sí mismo
Conjunto de un elementoundefined / null / tipo literal
Conjunto finitoboolean / unión
Conjunto infinitostring / number / object
Conjunto universalany / unknownTodo elemento pertenece a “any” y todo conjunto es subconjunto suyo / “unknown” es la alternativa segura de “any”

Estos son algunos ejemplos:

TypeScriptTérmino de conjuntosEjemplo
never∅ (conjunto vacío)const x: never = ‘x’; // Error: Type ‘string’ is not assignable to type ‘never’
Tipo literalConjunto de un elementotype X = ‘X’;
type Y = 7;
Valor asignable a TValor ∈ T (pertenece a)type XY = ‘X’ | ‘Y’;
const x: XY = ‘X’;
T1 asignable a T2T1 ⊆ T2 (subconjunto de)type XY = ‘X’ | ‘Y’;
const x: XY = ‘X’;
const j: XY = ‘J’; // Type ‘“J”’ is not assignable to type ‘XY’.
T1 extends T2T1 ⊆ T2 (subconjunto de)type X = ‘X’ extends string ? true : false;
T1 | T2T1 ∪ T2 (unión)type XY = ‘X’ | ‘Y’;
type JK = 1 | 2;
T1 & T2T1 ∩ T2 (intersección)type X = { a: string }
type Y = { b: string }
type XY = X & Y
const x: XY = { a: ‘a’, b: ‘b’ }
unknownConjunto universalconst x: unknown = 1

Una unión, (T1 | T2), crea un conjunto más amplio (ambos):

type X = {
a: string;
};
type Y = {
b: string;
};
type XY = X | Y;
const r: XY = { a: 'a', b: 'x' }; // Válido

Una intersección, (T1 & T2), crea un conjunto más restringido (solo lo compartido):

type X = {
a: string;
};
type Y = {
a: string;
b: string;
};
type XY = X & Y;
const r: XY = { a: 'a' }; // No válido
const j: XY = { a: 'a', b: 'b' }; // Válido

En este contexto, la palabra clave extends puede entenderse como «subconjunto de». Establece una restricción para un tipo. Cuando extends se utiliza con un genérico, restringe el parámetro de tipo genérico a un tipo más específico.

Ten en cuenta que aquí extends no tiene relación con la herencia de clases en el sentido de la programación orientada a objetos.

TypeScript trabaja con tipos estructurales y no posee una jerarquía nominal estricta. De hecho, como muestra el siguiente ejemplo, dos tipos pueden solaparse sin que ninguno sea subtipo del otro, porque TypeScript tiene en cuenta la estructura o forma de los objetos.

interface X {
a: string;
}
interface Y extends X {
b: string;
}
interface Z extends Y {
c: string;
}
const z: Z = { a: 'a', b: 'b', c: 'c' };
interface X1 {
a: string;
}
interface Y1 {
a: string;
b: string;
}
interface Z1 {
a: string;
b: string;
c: string;
}
const z1: Z1 = { a: 'a', b: 'b', c: 'c' };
const r: Z1 = z; // Válido

Asignar un tipo: declaraciones y aserciones de tipo

En TypeScript se puede asignar un tipo de distintas formas:

Declaración de tipo

En el siguiente ejemplo utilizamos x: X (”: Type”) para declarar un tipo para la variable x.

type X = {
a: string;
};
// Declaración de tipo
const x: X = {
a: 'a',
};

Si la variable no tiene el formato especificado, TypeScript notificará un error. Por ejemplo:

type X = {
a: string;
};
const x: X = {
a: 'a',
b: 'b', // Error: un literal de objeto solo puede especificar propiedades conocidas
};

Aserción de tipo

Es posible añadir una aserción mediante la palabra clave as. Esto indica al compilador que el desarrollador dispone de más información sobre un tipo y silencia los posibles errores.

Por ejemplo:

type X = {
a: string;
};
const x = {
a: 'a',
b: 'b',
} as X;

En el ejemplo anterior se afirma mediante la palabra clave as que el objeto x tiene el tipo X. Esto informa al compilador de TypeScript de que el objeto se ajusta al tipo especificado, aunque tenga una propiedad adicional b que no aparece en la definición del tipo.

Las aserciones de tipo resultan útiles cuando es necesario especificar un tipo más concreto, especialmente al trabajar con el DOM. Por ejemplo:

const myInput = document.getElementById('my_input') as HTMLInputElement;

Aquí, la aserción de tipo as HTMLInputElement indica a TypeScript que el resultado de getElementById debe tratarse como un HTMLInputElement. Las aserciones de tipo también pueden utilizarse para reasignar claves, como muestra el siguiente ejemplo con literales de plantilla:

type J<Type> = {
[
Property in keyof Type as `prefix_${string & Property}`
]: () => Type[Property];
};
type X = {
a: string;
b: number;
};
type Y = J<X>;

En este ejemplo, el tipo J<Type> utiliza un tipo mapeado con un literal de plantilla para reasignar las claves de Type. Crea propiedades nuevas añadiendo “prefix_” a cada clave, cuyos valores correspondientes son funciones que devuelven los valores de las propiedades originales.

Conviene señalar que TypeScript no ejecuta la comprobación de propiedades adicionales cuando se utiliza una aserción de tipo. Por tanto, suele ser preferible utilizar una declaración de tipo cuando se conoce de antemano la estructura del objeto.

Declaraciones de ambiente

Las declaraciones de ambiente son archivos que describen tipos para código JavaScript y cuyos nombres tienen el formato .d.ts. Normalmente se importan y utilizan para anotar bibliotecas JavaScript existentes o añadir tipos a archivos JS existentes del proyecto.

Los tipos de muchas bibliotecas habituales pueden encontrarse en: https://github.com/DefinitelyTyped/DefinitelyTyped/

y pueden instalarse mediante:

Ventana de terminal
npm install --save-dev @types/library-name

Para tus propias declaraciones de ambiente, puedes importar mediante una referencia de «triple barra»:

/// <reference path="./library-types.d.ts" />

Puedes utilizar declaraciones de ambiente incluso dentro de archivos JavaScript mediante // @ts-check.

La palabra clave declare permite definir tipos para código JavaScript existente sin importarlo y actúa como marcador de posición para tipos procedentes de otro archivo o del ámbito global.

Comprobación de propiedades y de propiedades adicionales

TypeScript se basa en un sistema de tipos estructural, pero la comprobación de propiedades adicionales permite verificar si un objeto tiene exactamente las propiedades especificadas en el tipo.

La comprobación de propiedades adicionales se realiza, por ejemplo, al asignar literales de objeto a variables o pasarlos como argumentos a funciones.

type X = {
a: string;
};
const y = { a: 'a', b: 'b' };
const x: X = y; // Válido por la tipificación estructural
const w: X = { a: 'a', b: 'b' }; // No válido por la comprobación de propiedades en exceso

Tipos débiles

Un tipo se considera débil cuando solo contiene un conjunto de propiedades opcionales:

type X = {
a?: string;
b?: string;
};

TypeScript considera erróneo asignar cualquier valor a un tipo débil cuando no existe solapamiento. Por ejemplo, lo siguiente genera un error:

type Options = {
a?: string;
b?: string;
};
const fn = (options: Options) => undefined;
fn({ c: 'c' }); // No válido

Aunque no se recomienda, si es necesario puede omitirse esta comprobación mediante una aserción de tipo:

type Options = {
a?: string;
b?: string;
};
const fn = (options: Options) => undefined;
fn({ c: 'c' } as Options); // Válido

O añadiendo unknown a la firma de índice del tipo débil:

type Options = {
[prop: string]: unknown;
a?: string;
b?: string;
};
const fn = (options: Options) => undefined;
fn({ c: 'c' }); // Válido

Comprobación estricta de literales de objeto (frescura)

La comprobación estricta de literales de objeto, a veces denominada «frescura», es una característica de TypeScript que ayuda a detectar propiedades adicionales o mal escritas que pasarían inadvertidas en las comprobaciones normales de tipos estructurales.

Al crear un literal de objeto, el compilador de TypeScript lo considera «fresco». Si se asigna a una variable o se pasa como parámetro, TypeScript generará un error cuando el literal especifique propiedades inexistentes en el tipo de destino.

Sin embargo, la «frescura» desaparece cuando se amplía un literal de objeto o se utiliza una aserción de tipo.

Estos son algunos ejemplos:

type X = { a: string };
type Y = { a: string; b: string };
let x: X;
x = { a: 'a', b: 'b' }; // Comprobación de frescura: asignación no válida
var y: Y;
y = { a: 'a', bx: 'bx' }; // Comprobación de frescura: asignación no válida
const fn = (x: X) => console.log(x.a);
fn(x);
fn(y); // Ampliación: sin errores, el tipo es estructuralmente compatible
fn({ a: 'a', bx: 'b' }); // Comprobación de frescura: argumento no válido
let c: X = { a: 'a' };
let d: Y = { a: 'a', b: '' };
c = d; // Ampliación: sin comprobación de frescura

Inferencia de tipos

TypeScript puede inferir tipos cuando no se proporciona ninguna anotación durante:

  • La inicialización de variables.
  • La inicialización de miembros.
  • La asignación de valores predeterminados a parámetros.
  • La determinación del tipo de retorno de una función.

Por ejemplo:

let x = 'x'; // El tipo inferido es string

El compilador de TypeScript analiza el valor o la expresión y determina su tipo a partir de la información disponible.

Inferencias más avanzadas

Cuando se utilizan varias expresiones en la inferencia de tipos, TypeScript busca los «mejores tipos comunes». Por ejemplo:

let x = [1, 'x', 1, null]; // El tipo inferido es: (string | number | null)[]

Si el compilador no encuentra los mejores tipos comunes, devuelve un tipo de unión. Por ejemplo:

let x = [new RegExp('x'), new Date()]; // El tipo inferido es: (RegExp | Date)[]

TypeScript utiliza el «tipado contextual», basado en la ubicación de la variable, para inferir tipos. En el siguiente ejemplo, el compilador sabe que e es de tipo MouseEvent por el tipo del evento click definido en el archivo lib.d.ts, que contiene declaraciones de ambiente para varias construcciones habituales de JavaScript y el DOM:

window.addEventListener('click', function (e) {}); // El tipo inferido de e es MouseEvent

Ampliación de tipos

La ampliación de tipos es el proceso mediante el cual TypeScript asigna un tipo a una variable inicializada sin una anotación de tipo. Permite pasar de tipos restringidos a tipos más amplios, pero no a la inversa. En el siguiente ejemplo:

let x = 'x'; // TypeScript infiere string, un tipo amplio
let y: 'y' | 'x' = 'y'; // El tipo de y es una unión de tipos literales
y = x; // No válido: el tipo 'string' no se puede asignar al tipo '"x" | "y"'.

TypeScript asigna string a x a partir del único valor proporcionado durante la inicialización (x); este es un ejemplo de ampliación.

TypeScript ofrece formas de controlar el proceso de ampliación, por ejemplo mediante “const”.

Const

Utilizar la palabra clave const al declarar una variable produce en TypeScript una inferencia de tipo más restringida.

Por ejemplo:

const x = 'x'; // TypeScript infiere el tipo de x como 'x', un tipo más restringido
let y: 'y' | 'x' = 'y';
y = x; // Válido: el tipo de x se infiere como 'x'

Al utilizar const para declarar la variable x, su tipo se restringe al valor literal concreto ‘x’. Como el tipo de x está restringido, puede asignarse a la variable y sin errores. El tipo puede inferirse así porque las variables const no pueden reasignarse, por lo que su tipo puede restringirse a un tipo literal concreto; en este caso, el tipo literal ‘x’.

Modificador const en parámetros de tipo

Desde TypeScript 5.0 es posible especificar el atributo const en un parámetro de tipo genérico. Esto permite inferir el tipo más preciso posible. Veamos un ejemplo sin utilizar const:

function identity<T>(value: T) {
// Aquí no se usa const
return value;
}
const values = identity({ a: 'a', b: 'b' }); // El tipo inferido es: { a: string; b: string; }

Como puedes ver, las propiedades a y b se infieren con el tipo string.

Veamos ahora la diferencia con la versión que utiliza const:

function identity<const T>(value: T) {
// Uso del modificador const en parámetros de tipo
return value;
}
const values = identity({ a: 'a', b: 'b' }); // El tipo inferido es: { a: "a"; b: "b"; }

Ahora podemos ver que las propiedades a y b se infieren como literales de cadena y no simplemente como tipos string.

Aserción const

Esta característica permite declarar una variable con un tipo literal más preciso según su valor de inicialización, indicando al compilador que debe tratar el valor como un literal inmutable. Estos son algunos ejemplos:

En una sola propiedad:

const v = {
x: 3 as const,
};
v.x = 3;

En un objeto completo:

const v = {
x: 1,
y: 2,
} as const;

Esto puede resultar especialmente útil al definir el tipo de una tupla:

const x = [1, 2, 3]; // number[]
const y = [1, 2, 3] as const; // Tupla de tipo readonly [1, 2, 3]

Anotación de tipo explícita

Podemos ser explícitos e indicar un tipo. En el siguiente ejemplo, la propiedad x es de tipo number:

const v = {
x: 1, // Tipo inferido: number (ampliación)
};
v.x = 3; // Válido

Podemos hacer que la anotación de tipo sea más específica mediante una unión de tipos literales:

const v: { x: 1 | 2 | 3 } = {
x: 1, // x ahora es una unión de tipos literales: 1 | 2 | 3
};
v.x = 3; // Válido
v.x = 100; // No válido

Restricción de tipos

La restricción de tipos es el proceso de TypeScript mediante el cual un tipo general se reduce a otro más específico. Ocurre cuando TypeScript analiza el código y determina que ciertas condiciones u operaciones pueden precisar la información del tipo.

Los tipos pueden restringirse de distintas formas, entre ellas:

Condiciones

Mediante sentencias condicionales como if o switch, TypeScript puede restringir el tipo según el resultado de la condición. Por ejemplo:

let x: number | undefined = 10;
if (x !== undefined) {
x += 100; // El tipo es number, que se ha restringido mediante la condición
}

Lanzar una excepción o devolver

Lanzar un error o devolver anticipadamente desde una rama puede ayudar a TypeScript a restringir un tipo. Por ejemplo:

let x: number | undefined = 10;
if (x === undefined) {
throw 'error';
}
x += 100;

Otras formas de restringir tipos en TypeScript son:

  • Operador instanceof: se utiliza para comprobar si un objeto es instancia de una clase concreta.
  • Operador in: se utiliza para comprobar si una propiedad existe en un objeto.
  • Operador typeof: se utiliza para comprobar el tipo de un valor en tiempo de ejecución.
  • Funciones integradas como Array.isArray(): se utilizan para comprobar si un valor es un array.

Unión discriminada

Una «unión discriminada» es un patrón de TypeScript en el que se añade una «etiqueta» explícita a los objetos para distinguir entre distintos tipos de una unión. Este patrón también se denomina «unión etiquetada». En el siguiente ejemplo, la propiedad “type” representa la etiqueta:

type A = { type: 'type_a'; value: number };
type B = { type: 'type_b'; value: string };
const x = (input: A | B): string | number => {
switch (input.type) {
case 'type_a':
return input.value + 100; // El tipo es A
case 'type_b':
return input.value + 'extra'; // El tipo es B
}
};

Guardas de tipo definidas por el usuario

Cuando TypeScript no puede determinar un tipo, es posible escribir una función auxiliar denominada «guarda de tipo definida por el usuario». En el siguiente ejemplo utilizaremos un predicado de tipo para restringir el tipo después de aplicar un filtro:

const data = ['a', null, 'c', 'd', null, 'f'];
const r1 = data.filter(x => x != null); // El tipo es (string | null)[], TypeScript no ha podido inferirlo correctamente
const isValid = (item: string | null): item is string => item !== null; // Guarda de tipo personalizada
const r2 = data.filter(isValid); // El tipo ahora es correcto: string[]; mediante la guarda con predicado de tipo hemos podido restringirlo

Restricción mediante switch-true

TypeScript 5.3 añade la restricción mediante switch-true, que permite sustituir cadenas if/else complejas por switch (true) con condiciones booleanas. Mejora la legibilidad y sigue restringiendo los tipos. Es similar a la coincidencia de patrones, pero más sencilla.

function classify(x: unknown) {
switch (true) {
case typeof x === 'string':
return `"${x.toUpperCase()}"`;
case typeof x === 'number':
return x > 0 ? 'positive' : 'negative';
case Array.isArray(x):
return `[${x.length} items]`;
default:
return 'something else';
}
}