Inicio / Artículos / Convenciones de TypeScript que mantienen el código estricto y flexible

Convenciones de TypeScript que mantienen el código estricto y flexible

Opiniones prácticas sobre TypeScript: modo estricto, unknown sobre any, afirmaciones dispersas, uniones de cadenas, tipo vs interfaz, propiedades JSDoc, utilidades y uniones discriminadas.

1535 palabras

Un buen uso de TypeScript debe reducir las posibilidades de enviar código incorrecto sin convertir cada archivo en un procedimiento complejo. Las convenciones que se detallan a continuación favorecen estructuras más simples, seguras y fáciles de mantener, al tiempo que conservan la flexibilidad que hace útil a TypeScript en el día a día.

Resumen

  • Especifique claramente los contratos en los límites de módulos y componentes.
  • Permita que el verificador infiera los auxiliares privados y las variables locales.
  • Mantenga las exclusiones pequeñas e intencionadas.
  • Utilice alias y herramientas existentes antes de crear nuevas.
  • Favorezca las uniones literales y las variantes etiquetadas cuando los estados sean alternativos.

Habilitar el modo estricto

Siempre active la verificación estricta en tsconfig.json (guía):

{
  "compilerOptions": {
    "strict": true
  }
}

Las opciones estrictas detectan errores comunes más temprano y mantienen un nivel más alto de corrección en una base de código en crecimiento.

Evitar el tipo any

Cuando el tipo de un valor es incierto, prefiera unknown en lugar de any, ya que su uso requiere primero delimitar el tipo:

// bad
function processValue(value: any) {
  value.toLowerCase(); // No error, but might crash at runtime
}

// good
function processValue(value: unknown) {
  if (typeof value === 'string') {
    value.toLowerCase(); // OK, we've verified it’s a string
  }
}

Ignorar las comprobaciones de tipo

Cuando realmente sea necesario eludir las comprobaciones, mantenga la solución de emergencia lo más limitada posible.

Convertir una única expresión con as any suele ser mejor que usar @ts-ignore, porque la conversión afecta solo a esa expresión, mientras que @ts-ignore puede silenciar toda la línea siguiente.

// third-party library with incomplete type definitions
import { someExternalLib } from 'external-lib';

const result = (someExternalLib.complexMethod() as any).undocumentedProperty;

En código propio, prefiera corregir los tipos, agregar protecciones o validar datos externos (por ejemplo con Zod) en lugar de silenciar el comprobador.

Use @ts-expect-error cuando sepa algo que el comprobador no puede demostrar:

  • A diferencia de @ts-ignore, la compilación fallará si el error desaparece inesperadamente.
  • Eso mantiene las supresiones intencionadas transparentes durante las revisiones.
<div
  style={{
    // @ts-expect-error — vars are valid CSS properties
    '--size': `${dimensions.size}px`,
  }}
/>

Use las aserciones de tipo con moderación

as anula la verificación automática. Prefiera la confirmación en tiempo de ejecución cuando sea posible:

// bad
const myCanvas = document.getElementById('canvas') as HTMLCanvasElement;

// good
const myCanvas = document.getElementById('canvas');
if (myCanvas instanceof HTMLCanvasElement) {
  const ctx = myCanvas.getContext('2d');
}

Prefiera los protectores de tipo sobre las aserciones

Los protectores solo restringen el rango después de que una verificación en tiempo de ejecución tenga éxito; son más seguros en caso de error que un simple as:

type User = {
  id: string;
  name: string;
};

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    typeof value.id === 'string' &&
    'name' in value &&
    typeof value.name === 'string'
  );
}

function greet(value: unknown) {
  if (isUser(value)) {
    // value is User here - no assertion needed
    console.log(`Hello, ${value.name}`);
  }
}

Use una aserción solo cuando sea imposible o inútil realizar una verificación en tiempo de ejecución, generalmente cuando los parámetros de terceros no coinciden con el patrón del formulario circundante:

<OTPInput
  // Our form pattern expects a traditional change handler but this third-party
  // component provides the text value as its only callback argument. We need
  // to manually create an event-like object so validation works as expected
  // for consumers.
  onChange={(value) => {
    props.onChange?.({
      target: { value },
    } as ChangeEvent<HTMLInputElement>);
  }}
/>

Prefiera las uniones de literales de cadena sobre los enums

Para el código de la aplicación, las uniones de literales de cadena suelen ser mejores que los enums: no generan objetos en tiempo de ejecución, tienen emisiones más simples y son más adecuadas para la serialización:

// bad
enum Environments {
  DEV = 'dev',
  TESTING = 'test',
  STAGING = 'staging',
  PRODUCTION = 'prod',
}

// good
type Environments = 'dev' | 'test' | 'staging' | 'prod';

Mantenga los enums cuando se requiera un objeto en tiempo de ejecución, cuando una API existente ya los utilice o cuando los estandares del equipo lo exijan.

Infiera uniones a partir de los valores

Cuando ya existen valores en tiempo de ejecución, derive la unión a partir de una única fuente de verdad:

const ENVIRONMENT_META = {
  dev: {},
  test: {},
  staging: {},
  prod: {},
};

type Environments = keyof typeof ENVIRONMENT_META;
//   ^? 'dev' | 'test' | 'staging' | 'prod'

Forma de array:

const ENVIRONMENTS = ['dev', 'test', 'staging', 'prod'] as const;
type Environments = (typeof ENVIRONMENTS)[number];
//   ^? 'dev' | 'test' | 'staging' | 'prod'

Se requiere una afirmación const para que el array siga siendo una tupla de literales en lugar de string[].

Preferir type sobre interface

Para la mayoría del código de aplicaciones, type abarca las estructuras de objetos además de uniones, intersecciones, tipos mapeados y condicionales. También impide que se vuelva a abrir accidentalmente para fusiones no deseadas:

// when required
interface Person {
  name: string;
  email: string;
}

// preferred
type Person = {
  name: string;
  email: string;
};

Establecer como valor por defecto una forma específica reduce el cambio innecesario entre funcionalidades superpuestas.

Usar interface solo cuando sus características son importantes

Las bibliotecas públicas pueden exportar intencionalmente interface para que los usuarios puedan ampliarlas:

// library
export interface Theme {
  colors: Record<string, string>;
}

// consumer: extend the interface without modifying the original source
declare module 'my-library' {
  interface Theme {
    spacing: Record<string, number>;
  }
}

Documentar las propiedades de los componentes

Utilice JSDoc (/** ... */) en las propiedades públicas o poco evidentes: su finalidad, comportamiento y restricciones, mientras el contexto esté fresco:

type SearchFieldProps = {
  /**
   * A unique identifier for the field. Consider [useId](https://react.dev/reference/react/useId) to generate a unique ID.
   */
  id?: string;
  /**
   * The label of the field. For "visually hidden" labels, use the `aria-label`
   * attribute.
   */
  label?: string;
  /**
   * @deprecated Use `label` instead.
   */
  title?: string;
  /**
   * Handler that is called when the field is submitted.
   */
  onSubmit?: (value: string) => void;
  /**
   * Handler that is called when the clear button, or <Escape> key, is pressed.
   */
  onClear?: () => void;
} & AriaLabellingProps;

Excepciones en la documentación

Omita documentos extensos cuando:

  • El componente se utiliza una sola vez y es poco probable que se vuelva a usar
  • Es un hijo privado de un padre documentado cuyo contrato ya explica su comportamiento

Prefiera la claridad cuando no esté seguro y mantenga la coherencia.

Reutilizar tipos existentes

Haga que las API públicas sean explícitas; deje que la inferencia se encargue de los aspectos internos. Antes de inventar una nueva abstracción, busque un tipo, propiedad o utilidad existente para reutilizarlo, de modo que el código relacionado permanezca alineado.

Acceder a propiedades

El acceso por índice obtiene el tipo de una propiedad a partir de un alias existente:

import type { ThingProps } from '../thing';

type SpecialThingProps = {
  ...
  label?: ThingProps['label'];
  type?: ThingProps['type'];
};

function SpecialThing(props: SpecialThingProps) {}

Preferir intersecciones para la composición

Combinar tipos sin duplicar campos (ni sus documentaciones):

type Person = { name: string; email: string };
type User = Person & { id: string };
//   ^? { name: string; email: string; id: string }
import type { ThingProps } from '../thing';

type SpecialThingProps = {
  ...
} & Pick<ThingProps, 'label' | 'type'>;

function SpecialThing(props: SpecialThingProps) {}

Detalles de implementación de inferencia

La inferencia mantiene los tipos internos sincronizados con los valores:

function initContext() {
  return {
    name: 'Jane Doe',
    email: 'jane.doe@example.com',
  };
}

type Context = ReturnType<typeof initContext>;
//   ^? { name: string; email: string }

ComponentProps deriva las propiedades de un componente React cuando la biblioteca no las exporta:

import type { ComponentProps } from 'react';
import { RouterProvider } from 'react-aria-components';

type RouterProviderProps = ComponentProps<typeof RouterProvider>;
//  {
//    navigate: (path: Href, routerOptions: RouterOptions | undefined) => void;
//    useHref?: (href: Href) => string;
//    children: ReactNode;
//  }

Parameters genera una tupla de argumentos a la que se puede acceder por índice:

// library code

declare function someLibFn(
  value: number,
  options: {
    minimumFractionDigits: number;
    maximumFractionDigits: number;
  }
): void;

// consumer code

import { someLibFn } from 'some-lib';

type LibFnValue = Parameters<typeof someLibFn>[0];
//   ^? number
type LibFnOptions = Parameters<typeof someLibFn>[1];
//   ^? { minimumFractionDigits: number, maximumFractionDigits: number }

Aproveche los tipos de utilidad integrados

Los tipos de utilidad abarcan transformaciones comunes y resultan familiares para los nuevos miembros del equipo:

type User = {
  id: string;
  name: string;
  email: string;
};

type UserPatch = Partial<User>;
type UserDetails = Omit<User, 'id'>;
type UserIdentity = Pick<User, 'id' | 'email'>;

Escriba una utilidad personalizada solo cuando el nombre de la transformación corresponda a un concepto real del dominio, y no simplemente para ahorrar teclas.

Utilice uniones discriminadas

Las uniones etiquetadas modelan alternativas para que la verificación de exhaustividad funcione correctamente:

type Actions =
  | { type: 'login'; username: string }
  | { type: 'logout'; reason: 'session-timeout' | undefined }
  | { type: 'update'; id: string; data: Record<string, unknown> }

Un campo literal compartido (type o kind) permite a TypeScript restringir cada rama de forma segura.

Resumen

La coherencia supera a cualquier regla individual. Acuerden las convenciones, documenten las excepciones y optimicen para el próximo lector:

  • Contratos de límites claros
  • Elementos internos inferidos
  • Supresiones mínimas
  • Alias y utilidades compartidos
  • Uniones etiquetadas donde las combinaciones ilegales deben desaparecer

Aplicados juntos, estos hábitos mantienen a TypeScript como una herramienta de diseño y no como una carga: las APIs públicas permanecen transparentes, los componentes internos siguen siendo DRY, y los castings restantes de any o as se vuelven algo excepcional, susceptibles de revisión en lugar de ser la vía de escape por defecto.

Dichas referencias se encuentran en el manual oficial de TypeScript y en los documentos JSDoc para quienes deseen la redacción canónica.

Traten esta lista como un acuerdo vivo del equipo y no como una doctrina fija para siempre. De acuerdo.

Lecturas relacionadas