Skip to content

Commit df6218a

Browse files
joshmandersdunnbot
andcommitted
feat: codegen useForm field and error-key types from FormRequest rules
Closes #17 Co-Authored-By: Dunnbot <dunnbot@joshmanders.com>
1 parent 8ecad7d commit df6218a

9 files changed

Lines changed: 820 additions & 9 deletions

File tree

README.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# vite-plugin-ferry
22

3-
> Type-safe Inertia apps end to end — generates TypeScript for routes, enums, resources, and page props straight from your Laravel backend.
3+
> Type-safe Inertia apps end to end — generates TypeScript for routes, enums, resources, page props, and form types straight from your Laravel backend.
44
55
[![npm version](https://img.shields.io/npm/v/vite-plugin-ferry.svg?style=flat-square)](https://www.npmjs.com/package/vite-plugin-ferry)
66
[![npm downloads](https://img.shields.io/npm/dt/vite-plugin-ferry.svg?style=flat-square)](https://www.npmjs.com/package/vite-plugin-ferry)
@@ -12,6 +12,7 @@
1212
- 🏷️ **Enums** — PHP enums become real JS classes with `is`/`from`/`fromOrFail`/`values`/`keys`/`cases`/`options`, narrowed to their literal value union
1313
- 📦 **Resources** — precise types for your `JsonResource` classes from static shape analysis plus real column/cast metadata, degrading gracefully instead of breaking your build
1414
- 🧩 **Page props** — the props an Inertia page receives, typed through `usePage<T>()` and Inertia's own `sharedPageProps` augmentation
15+
- 📝 **Form types** — the data shape of your `FormRequest` classes, typed through `useForm<T>()` with `form.errors` keys derived for free
1516
- ⚛️ **React, Vue & Svelte**`route()` and `route.isCurrent()` resolve through ferry on every frontend, in dev and production (the `.url` codemod sugar is React/plain-TS only)
1617

1718
Nothing is written into your project tree. Runtime code is delivered as Vite virtual modules, types as one generated ambient `.d.ts`, and everything regenerates on every run.
@@ -234,6 +235,48 @@ public function share(Request $request): array
234235

235236
When `share()` calls `parent::share()`, ferry follows it into an app-local base middleware and merges that parent's shared props in too — the child wins on any key collision. A vendor or otherwise unlocatable parent (such as Inertia's base `Middleware`) is skipped silently.
236237

238+
### Form types — `@ferry/forms`
239+
240+
Ferry reads every `FormRequest`'s `rules()` and generates a data-shape type per request, named by the class's verbatim short name and served from `@ferry/forms`. Pass it to `useForm<T>()` to type both the form data and — for free — the `form.errors` keys, which Inertia derives from the same shape via its own `FormDataKeys<T>`, including nested (`profile.bio`) and array (`items.0.id`) paths.
241+
242+
```php
243+
// app/Http/Requests/StoreUserRequest.php
244+
public function rules(): array
245+
{
246+
return [
247+
'name' => 'required|string',
248+
'age' => 'nullable|integer',
249+
'role' => 'required|in:admin,editor,viewer',
250+
'profile.bio' => ['nullable', 'string'],
251+
'items.*.id' => ['required', 'integer'],
252+
];
253+
}
254+
```
255+
256+
```ts
257+
declare module '@ferry/forms' {
258+
export type StoreUserRequest = {
259+
name: string;
260+
age: number | null;
261+
role: 'admin' | 'editor' | 'viewer';
262+
profile: { bio: string | null };
263+
items: { id: number }[];
264+
};
265+
}
266+
```
267+
268+
```tsx
269+
// resources/js/Pages/Users/Create.tsx
270+
import type { StoreUserRequest } from '@ferry/forms';
271+
272+
const form = useForm<StoreUserRequest>({ name: '', age: null, role: 'admin', profile: { bio: null }, items: [] });
273+
form.data.role; // 'admin' | 'editor' | 'viewer'
274+
form.errors['profile.bio']; // typed error key, derived from the shape
275+
form.errors['items.0.id']; // wildcard array paths resolve too
276+
```
277+
278+
Rule tokens map to leaf types — `string`/`email`/`url`/`uuid`/`date``string`, `integer`/`numeric`/`decimal``number`, `boolean``boolean`, `in:a,b,c` → a string-literal union, and `array``any[]` unless nested keys describe its shape. Dotted keys nest (`profile.bio``profile: { bio: ... }`) and a `*` segment becomes an array (`items.*.id``items: { id: ... }[]`). `nullable` unions `| null` onto the value; `sometimes` makes the key optional; every other field is present, since a form initializes all of them. A field whose only rule ferry can't map to a type — a `Rule::` object, a closure, or a rule with no type signal — degrades to `any` (or `unknown` under [`strict`](#configuration)) with a warning. A form with no matching `FormRequest` generates no type, and its `useForm()` call simply omits the generic as before.
279+
237280
## Configuration
238281

239282
```ts

src/delivery/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import { join } from 'node:path';
2-
import { VirtualModuleRegistry, DtsRegistry, ModuleFileRegistry } from './registry.js';
32
import { writeAmbientTypes } from './ambient-types.js';
43
import { ENUM_BASE_RUNTIME, ENUM_BASE_DTS } from './enum-base.js';
4+
import { VirtualModuleRegistry, DtsRegistry, ModuleFileRegistry } from './registry.js';
55

66
export * from './registry.js';
77
export * from './ambient-types.js';
88
export * from './enum-base.js';
99

1010
/** The ferry virtual module ids served at runtime. */
11-
export const FERRY_MODULE_IDS = ['@ferry/enums', '@ferry/enum', '@ferry/resources', '@ferry/route', '@ferry/pages'] as const;
11+
export const FERRY_MODULE_IDS = [
12+
'@ferry/enums',
13+
'@ferry/enum',
14+
'@ferry/resources',
15+
'@ferry/route',
16+
'@ferry/pages',
17+
'@ferry/forms',
18+
] as const;
1219

1320
/** Runtime placeholder for feature modules whose real content arrives in a later build-order step. */
1421
const PLACEHOLDER_RUNTIME = 'export {};\n';
@@ -40,7 +47,7 @@ function registerDefaults(virtual: VirtualModuleRegistry, dts: DtsRegistry): voi
4047
virtual.register('@ferry/enum', ENUM_BASE_RUNTIME);
4148
dts.register('@ferry/enum', ENUM_BASE_DTS);
4249

43-
for (const id of ['@ferry/route', '@ferry/pages']) {
50+
for (const id of ['@ferry/route', '@ferry/pages', '@ferry/forms']) {
4451
virtual.register(id, PLACEHOLDER_RUNTIME);
4552
dts.register(id, placeholderDts(id));
4653
}

0 commit comments

Comments
 (0)