Saltar al contenido
frontend / · 13 min

TypeScript en Laravel, de cero a dominarlo: el panorama completo

Que es TypeScript, donde vive en un proyecto Laravel, como se tipan los props que manda el controlador y por que tu build pasa aunque los tipos esten mal. La guia que me hubiera ahorrado el primer mes.

Administrador · Desarrollador Laravel
TypeScript en Laravel, de cero a dominarlo: el panorama completo

Este blog tiene 103 archivos .tsx y 115 .ts. Ninguno de ellos llega al navegador: todos se borran al compilar.

Si esa frase te suena rara, este texto es para ti. Voy a contar qué es TypeScript, dónde vive dentro de un proyecto de Laravel, qué tipas tú y qué se tipa solo, y por qué el npm run build puede pasar limpio con los tipos rotos. Es lo que me hubiera ahorrado el primer mes.

El problema que TypeScript resuelve

Tienes un controlador que manda datos a una página:

// app/Http/Controllers/Blog/CategoriaController.php
return Inertia::render('blog/categorias/index', [
    'categorias' => $categorias,
    'paginacion' => $paginacion,
]);

Y una página de React que los recibe. En JavaScript puro, esa página no tiene forma de saber qué le llegó. Escribes categoria.nombre y funciona. Escribes categoria.nombreCompleto y también «funciona»: te da undefined, React lo pinta como nada, y te enteras cuando alguien reporta que la columna sale vacía.

El día que renombras una columna en la base, el frontend no se entera. El día que el controlador deja de mandar un campo, el frontend no se entera. Nada truena. Simplemente hay huecos.

TypeScript es la respuesta a eso: escribes lo que esperas recibir, y una herramienta comprueba que el resto del código sea coherente con eso.

Qué es TypeScript, en dos minutos

TypeScript es JavaScript con anotaciones de tipo. Esto es JavaScript:

function saludar(nombre) {
    return `Hola ${nombre}`;
}

Y esto es TypeScript:

function saludar(nombre: string): string {
    return `Hola ${nombre}`;
}

Los : string son la anotación. Si alguien llama saludar(42), TypeScript lo marca como error antes de ejecutar nada.

Tres cosas que hay que entender de entrada, porque explican todo lo demás:

1. Los tipos no existen en tiempo de ejecución. Al compilar, las anotaciones se borran. Lo que llega al navegador es el JavaScript de arriba, sin : string. TypeScript es un corrector de textos, no un guardia en la puerta.

2. No tienes que anotarlo todo. TypeScript infiere. Si escribes const total = 5 + 3, ya sabe que total es number sin que se lo digas. Anotas donde la información entra al sistema: parámetros de función, props de componente, respuestas de una API.

3. any apaga todo. Es el tipo que dice «confía en mí». Cada any es un agujero por donde vuelve el problema original. Se usa a veces; nunca por comodidad.

Dónde vive TypeScript en un proyecto de Laravel

Tres piezas, y ninguna toca PHP:

tsconfig.json           <- la configuración
resources/js/**/*.ts    <- lógica, tipos, utilidades
resources/js/**/*.tsx   <- componentes de React (la x es por el JSX)

El tsconfig.json es el archivo que dice qué se revisa y con qué severidad. El de este blog, sin los comentarios:

{
    "compilerOptions": {
        "target": "ESNext",
        "moduleResolution": "bundler",
        "noEmit": true,
        "strict": true,
        "noImplicitAny": true,
        "skipLibCheck": true,
        "baseUrl": ".",
        "paths": {
            "@/*": ["./resources/js/*"]
        },
        "jsx": "react-jsx"
    },
    "include": [
        "resources/js/**/*.ts",
        "resources/js/**/*.d.ts",
        "resources/js/**/*.tsx"
    ]
}

Las cinco líneas que de verdad importan:

  • "noEmit": true — TypeScript no genera archivos. Solo revisa. De compilar se encarga Vite. Volveré a esto porque es la fuente de la confusión más común.
  • "strict": true — enciende todas las comprobaciones. Es más incómodo al principio y es lo único que hace que el esfuerzo valga la pena. Un TypeScript sin strict te da la ilusión de seguridad sin la seguridad.
  • "noImplicitAny": true — si TypeScript no puede deducir un tipo y tú no lo escribiste, es error. Sin esto, los agujeros aparecen solos.
  • "paths" — el alias @/. Por eso los imports dicen @/components/ui/button y no ../../../components/ui/button. Ojo: Vite necesita saber lo mismo por su lado; en un proyecto de Laravel el plugin ya lo resuelve.
  • "jsx": "react-jsx" — permite escribir JSX sin importar React en cada archivo.

La regla que explica el 80% de la confusión

Vite no revisa tipos.

Vite (a través de esbuild) borra las anotaciones y sigue. Es rapidísimo justo porque no comprueba nada. Así que esto pasa todos los días:

$ npm run build
✓ built in 4.21s          # todo bien

...con un error de tipos en tu código. El build pasó porque a Vite los tipos le dan igual.

Quien revisa es el compilador de TypeScript, y hay que llamarlo aparte. Por eso en package.json hay este guion:

"scripts": {
    "types:check": "tsc --noEmit"
}
$ npm run types:check
resources/js/pages/blog/categorias/index.tsx:52:38 - error TS2339:
Property 'nombreCompleto' does not exist on type 'CategoriaRow'.

Ahí sí. Si types:check no corre en tu integración continua, TypeScript no te está protegiendo de nada: es documentación con la que nadie se compromete. Es el error número uno que veo en proyectos que «usan TypeScript».

En el editor sí se ve al momento, porque VS Code corre el compilador por su cuenta. Pero el editor es de una persona; la CI es del equipo.

Lo primero que vas a tipar: los props de una página

Aquí es donde TypeScript se gana el sueldo en un proyecto de Laravel. El controlador manda datos, la página los recibe, y tú describes la forma:

// resources/js/pages/blog/categorias/index.tsx

type CategoriaRow = {
    id: number;
    nombre: string;
    slug: string;
    descripcion: string | null;
    publicaciones?: number;
    created_at: string;
};

export default function CategoriasIndex({
    categorias,
    paginacion,
}: {
    categorias: CategoriaRow[];
    paginacion: DataTableServer;
}) {
    // ...
}

Lee el tipo con calma, porque cada símbolo dice algo concreto:

Escrito así Significa
id: number Siempre viene, y es número
descripcion: string | null Siempre viene, y puede ser nulo
publicaciones?: number Puede no venir, y si viene es número
CategoriaRow[] Un arreglo de eso

La diferencia entre | null y ? es la misma que en tu base de datos entre una columna nullable y una columna que a veces no seleccionas. Distinguirlas es la mitad del beneficio: TypeScript te va a obligar a preguntar antes de usar algo que declaraste opcional.

// error: 'descripcion' es posiblemente null
<p>{categoria.descripcion.toUpperCase()}</p>

// bien
<p>{categoria.descripcion?.toUpperCase() ?? 'Sin descripcion'}</p>

Ese error, en JavaScript, es la pantalla en blanco que aparece un martes con el registro que sí tenía la descripción vacía.

Un consejo sobre las fechas

created_at: string, no Date. Lo que viaja en JSON es una cadena. Es tentador escribir Date porque conceptualmente es una fecha, pero mentirle a TypeScript sobre lo que llega es peor que no tiparlo: te va a dejar llamar métodos de Date sobre algo que en tiempo de ejecución es texto, y ahí sí truena en el navegador.

Lo segundo: los props compartidos

Hay datos que Laravel manda en todas las páginas: el usuario, los permisos, los mensajes flash. En Inertia se declaran en HandleInertiaRequests y los recoges con usePage().

Repetir ese tipo en cada página sería absurdo. Se declara una vez, y es el truco más útil que aprendí:

// resources/js/types/global.d.ts
import type { Auth } from '@/types/auth';

declare module '@inertiajs/core' {
    export interface InertiaConfig {
        sharedPageProps: {
            name: string;
            auth: Auth;
            sidebarOpen: boolean;
            flash?: {
                success?: string | null;
                error?: string | null;
            };
            [key: string]: unknown;
        };
    }
}

Lo que hace declare module es entrar a la biblioteca de otro y añadirle información. Le estás diciendo a Inertia cómo son tus props compartidos. A partir de ahí, en cualquier componente:

const { auth } = usePage().props;
auth.user.name        // autocompletado
auth.user.nombre      // error, y con razón

Un archivo .d.ts es «solo declaraciones»: no genera código, solo describe. Por eso el include del tsconfig los menciona aparte.

Y los tipos que se comparten viven juntos, exportados desde un solo lugar:

// resources/js/types/index.ts
export type * from './auth';
export type * from './navigation';
export type * from './ui';

Formularios: donde más se agradece

Inertia trae useForm, y le pasas el tipo de lo que maneja:

type CategoriaForm = {
    id: number | null;
    nombre: string;
    slug: string;
    descripcion: string;
};

const VALORES_INICIALES: CategoriaForm = {
    id: null,
    nombre: '',
    slug: '',
    descripcion: '',
};

const form = useForm<CategoriaForm>(VALORES_INICIALES);

form.data.nombre           // string
form.setData('nomber', 'x') // error: 'nomber' no existe
form.errors.nombre         // string | undefined

Fíjate en form.errors: TypeScript sabe que las llaves de los errores son las mismas del formulario. Un form.errors.nomrbe mal escrito —que en JavaScript es undefined y por lo tanto «sin error», y por lo tanto un mensaje de validación que nunca se muestra— aquí no compila.

Nota el descripcion: string en el formulario contra descripcion: string | null en la fila. No es descuido: un input de HTML nunca da null, da cadena vacía. Los tipos separados describen dos cosas distintas — lo que llega del servidor y lo que maneja el formulario — y está bien que no sean iguales. Forzar un solo tipo para ambos es un error de principiante que se paga con conversiones por todos lados.

Las rutas también son tipos

Si usas Wayfinder, las rutas de Laravel se generan como funciones de TypeScript:

import { destroy, index, store } from '@/routes/blog/categorias';

Y el archivo generado trae hasta la referencia al controlador:

/**
 * @see \App\Http\Controllers\Blog\CategoriaController::index
 * @see app/Http/Controllers/Blog/CategoriaController.php:18
 * @route '/blog/categorias'
 */
export const index = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
    url: index.url(options),
    method: 'get',
});

Una ruta mal escrita deja de compilar en vez de dar 404 en producción. Lo conté a fondo en Wayfinder: el año que dejé de usar Ziggy; aquí basta con saber que no escribes esos tipos, se generan de tus rutas de PHP.

Los cinco errores que vas a ver, y qué significan

TS2339: Property 'x' does not exist on type 'Y' Escribiste un campo que el tipo no declara. O te equivocaste al escribirlo, o el tipo está desactualizado respecto al controlador. Las dos causas son útiles de descubrir.

TS18047: 'x' is possibly 'null' Declaraste algo como nullable y lo usaste sin preguntar. Usa ?., ??, o un if antes.

TS2345: Argument of type 'A' is not assignable to parameter of type 'B' Le pasaste a una función algo que no encaja. Lee de abajo hacia arriba: el compilador termina con la diferencia concreta, y esa última línea suele ser toda la respuesta.

TS7006: Parameter 'x' implicitly has an 'any' type Es noImplicitAny trabajando. Casi siempre en un callback: items.map((item) => ...) donde items no está tipado. Arregla el origen, no el callback.

TS2322: Type 'string' is not assignable to type 'number' El clásico del id que viene como texto. Ojo aquí: si el error aparece con datos del servidor, el tipo puede tener razón y tu backend estar mandando otra cosa. Es una de las veces en que TypeScript encuentra un error real de PHP.

Una recomendación para leer errores largos: los de genéricos anidados asustan por el tamaño. Salta al final. TypeScript va desglosando y la incompatibilidad de verdad está en la última o penúltima línea.

Lo que TypeScript NO hace

Esto es lo más importante del texto, y lo que menos se dice.

No valida nada en tiempo de ejecución. Escribir edad: number no impide que el servidor mande "42". Los tipos se borraron al compilar. Si el controlador cambia y tu tipo no, TypeScript sigue tan feliz — está comprobando que tu código sea coherente con lo que tú declaraste, no con lo que realmente llega.

Dicho de otra forma: los tipos de props de página son una promesa entre tú y tu yo del futuro, no un contrato verificado con PHP. Si quieres verificación de verdad hay que generar los tipos desde los modelos o validar en el borde con algo como Zod. Las dos opciones tienen costo; para una aplicación interna con un solo equipo, escribirlos a mano y ser disciplinado suele ser suficiente.

No sustituye la validación del servidor. Nunca. El navegador es del usuario.

No arregla un mal diseño. Tipar un objeto de doce campos opcionales solo documenta que ese objeto está mal pensado.

Cómo empezar si ya tienes un proyecto en marcha

No conviertas todo un lunes. El camino que funciona:

  1. Instala TypeScript y crea el tsconfig.json con strict: true desde el principio. Empezar laxo y apretar después es mucho más doloroso, porque el código se escribe contra las reglas que hay.
  2. allowJs: true deja convivir .js y .ts. Nada se rompe el primer día.
  3. Convierte una pantalla completa, no archivos sueltos. Renombra a .tsx, tipa los props, arregla lo que salga. Una pantalla entera te enseña el flujo completo; media pantalla solo da frustración.
  4. Agrega types:check a la CI el mismo día. Aunque falle. Que falle es información.
  5. De ahí en adelante, todo lo nuevo en TypeScript. Lo viejo se migra cuando lo tocas por otra razón.

En este blog el proceso tomó semanas, no un fin de semana, y hubo un momento a la mitad en que había .js y .tsx conviviendo y se veía peor que al principio. Es normal y se pasa.

El resumen que te llevarías si solo leyeras esto

  • TypeScript es JavaScript con anotaciones que se borran al compilar.
  • Vite no revisa tipos. tsc --noEmit sí. Ponlo en la CI o no cuenta.
  • Lo primero que se tipa son los props que manda el controlador de Laravel.
  • ? y | null significan cosas distintas, y esa distinción es la mitad del valor.
  • Los props compartidos se declaran una vez con declare module '@inertiajs/core'.
  • Con Wayfinder, las rutas se tipan solas desde PHP.
  • strict: true desde el día uno; any solo cuando de verdad no hay otra.
  • Un tipo es una promesa tuya, no una verificación del backend.

Lo que ganas no es que el código sea más elegante. Es que el error que antes descubría un usuario ahora lo descubre tu editor, tres semanas antes y gratis.

#inertia #typescript #laravel #react
Comentarios 0

Nadie ha comentado todavía. Estrena la sección.

Deja tu comentario
Tu correo no se publica. Reviso los comentarios antes de publicarlos.

Seguir leyendo