Every app in PIM that needs several pieces of information at once — a contact’s name and phones and emails, an appointment’s date and time and location — used to reach for its own way of doing it, some ad-hoc combination of prompts and JavaFX code with nothing shared between apps. Deciding to build a real, reusable form system raised an odd question first: which module does a form even belong in?

The instinct is “the GUI,” since a form is visibly a UI thing — text fields, date pickers, a Save button. That instinct is wrong for PIM specifically. Every module downstream of the GUI — AI, storage, every pimi app — never wants to think about a form as widgets at all; it wants a FormField’s values flowing straight into an EventMessage, the same way any other user input does. A form’s whole purpose is to end as an event. That’s why the contract lives in events, PIM’s shared kernel, not in the GUI module or in some new wrapper module built just to hold it — events, security, and profile already exist as separate shared-kernel exceptions for their own distinct reasons, and forms didn’t need a fourth.

The actual type is a sealed interface: FormField at the top, an abstract base holding the logic every field type shares (key, label, required, multi-value handling, validation), and seven concrete leaves underneath — text, multiline text, number, date, email, phone, select. events still can’t depend on JavaFX, so rendering happens through double dispatch: a FormFieldVisitor interface events defines but never implements, and the GUI module writes the one implementation that actually knows how to draw a TextField or a DatePicker.

The part worth remembering isn’t the pattern itself — double dispatch is old — it’s what sealed buys for free once you use it here. Nothing outside events is allowed to add an eighth leaf type, because the sealed permits clause says so at compile time, not in a comment somewhere hoping people read it. And because the visitor interface is abstract, a renderer that doesn’t yet know how to draw every single leaf type simply fails to compile. Add a new field type to the seven, and the build breaks everywhere it needs teaching, instead of quietly working everywhere except the one form nobody remembered to update.

Migrating the first real app onto it turned up an honest, small improvement along the way: Contacts' name field had always been required on the server, but nothing told you that until after you’d already submitted the form. Once it went through the new typed system, marking it required=true was a one-line change that moved that same rule to where the user could actually see it before clicking Save. Not the point of the exercise, but exactly the kind of gap this sort of unification tends to surface.