TOC click here ↑
A Killer Solution for Building Enterprise-Level Forms
- 🚧 In Active Development 🏗️ -
Motivation • Getting Started • FormML Model Reference • API Reference • Known Issues • Roadmap • FAQs • Contacts
Key Features
👨👩👧👦 Non-dev friendly DSL
🛡️ Next-level type safety
🚀 Faster than Starship
🔌 Framework agnostic
🔄 First-class dynamic forms support (WIP)
⚡ First-class real-time forms support (WIP)
FormML (Form Modeling Language, pronounced as "formal") is a DSL-based, model-first, full-stack framework for building enterprise-level forms.
It's under active development now and currently provides official React bindings.
⭐️ Please Star this repo to show your support and encourage me to keep building! 🙏
💖 Your support means the world to me! 🫰
Forms can be simple (a sign-up or a survey) or complex (a loan application or a tax return). While there are many powerful tools in the ecosystem for building basic forms, such as Formik and React Hook Form, there isn't yet a serious solution (to my knowledge) that specifically addresses the pain points of complex forms - the Enterprise-Level forms.
Imagine you're a financial company building an online loan application form. What challenges might you face?
- Non-Tech Stakeholder Involvement: Loans are serious business involving specialized knowledge across finance, accounting, and legal domains - knowledge typically held only by non-technical experts. A key challenge is enabling these stakeholders to lead form design while maintaining smooth collaboration with developers.
- Branded UI & Custom UX: Developers matter too! Companies don't want cookie-cutter form designs. Every serious enterprise wants to build their brand and deliver unique user experiences, goals that require developer expertise to achieve.
- Calculations, Formulas & Dynamic Behavior: Loan amounts, monthly payments and other fields need real-time calculation through formulas. Different fields also need to be shown or hidden based on the selected "loan type".
- Auto-save & Resume: Complex forms can have hundreds of fields - users don't want to start over if they accidentally close their browser.
- Others: Performance, validation, prefilling, and more.
These aren't niche problems specific to certain scenarios, but common challenges enterprises face when building complex forms. We call all the forms with similar pain points "Enterprise-Level" forms.
- Non-Tech Stakeholder Involvement ➡️ Non-Dev Friendly DSL: As its full name "Form Modeling Language" suggests, FormML's core is a DSL for modeling forms. It was designed from the ground up for non-developers, with simple structure, minimal syntax, and more natural terminology (e.g., "text" instead of "string").
- Branded UI & Custom UX ➡️ Model-View Separation: FormML DSL focuses on modeling form business logic. Once the model (
.fml
file) is complete, UI/view implementation is entirely in developers' hands and fully customizable. - Calculations, Formulas & Dynamic Behavior ➡️ First-class dynamic forms support & Excel-like formula (WIP)
- Auto-save & Resume ➡️ First-class real-time forms support (WIP)
- Others: Performance (reactivity system based on @vue/reactivity), validation (annotations based on valibot), prefilling (plugin system)
💡 You may not need to read everything
- 📝 For non-technical people (FormML Model authors): You only need to understand the Create Your First FormML Model section
- 👨💻 For developers: It's recommended to read all
At its core, FormML features a non-developers friendly DSL (Domain-Specific Language) designed to describe form structure, types, and logic. These descriptions are typically written in one (or a set of) .fml
files. All form definitions within .fml
files together constitute a FormML Model.
Let's create your first FormML Model step by step.
- Install and open Visual Studio Code
- Install the FormML extension (VSCode only for now)
- Create a
sign-up.fml
file anywhere - Enter the following content:
form SignUp {
text name
text email
text password
datetime birthday
}
Congratulations! You've just created a simplest FormML Model.
Explanation for beginners
This code demonstrates 2 core syntactic elements of FormML DSL:
form SignUp { ... }
: Defines a form namedSignUp
, with its contents enclosed within the{ ... }
code blocktext name
: Defines a form field namedname
of typetext
In FormML DSL, fields are optional by default (since required fields are typically the minority in complex forms).
To make a field required, you can add the @required
annotation. Let's make name
, email
, and password
required:
form SignUp {
@required
text name
@required
text email
@required
text password
datetime birthday
}
Now these three fields cannot be empty, or users will see error messages.
We can further enhance field validation by adding more annotations or modifying annotation parameters:
form SignUp {
@required("Let me know your cool name!")
text name
@required @email
text email
@required @minLength(8)
text password
datetime birthday
}
Explanation for beginners
Here's what we changed/added:
@required("Let me know your cool name!")
: Added a new parameter toname
field's@required
annotation, that specifies a custom error message@email
: Added the@email
annotation toemail
field to validate text format@minLength(8)
: Added the@minLength
annotation with parameter 8 topassword
field to enforce a minimum length of 8 characters
Perfect! You now have your first complete FormML Model. For more FormML DSL syntax, please refer to the FormML Model Reference section.
Once you have your FormML Model ready (either written by yourself or from non-technical experts), you can start to create your form UI with React now.
💡 FormML itself is framework agnostic. But for now only the React bindings is ready.
To import .fml
files directly into your JS/TS files, you'll need to complete these 2 setups firstly:
Set up your bundler
FormML provides a Vite/Rollup plugin for importing .fml
files in JavaScript (other bundlers support is coming soon). Here's how to set it up with Vite (Rollup configuration is similar):
1 - Install the plugin
npm install rollup-plugin-formml --save-dev
2 - Edit your vite.config.ts
file to enable the plugin:
...
import formml from 'rollup-plugin-formml'
export default defineConfig({
...
plugins: [formml()],
})
Set up TypeScript
To provide real-time type checking (even after editing .fml
files), FormML uses TypeScript's Language Service Plugin feature. Here's how to set it up:
See Why not code generation (like Prisma)? to learn the reason of this decision.
TypeScript Language Service Plugin only affects your editing experience, meaning FormML Model's type information won't be included when running the
tsc
command.For more information, see: Using
tsc
to check FormML Model types.
1 - (For VSCode users) VSCode defaults to using the global TypeScript version which won't load the plugin correctly. Configure VSCode to use the workspace version of TypeScript
2 - Install the FormML TypeScript plugin
npm install @formml/ts-plugin --save-dev
3 - Edit your tsconfig.json
file to enable the plugin:
{
"compilerOptions": {
"plugins": [{ "name": "@formml/ts-plugin" }]
}
}
4 - Restart TS Server to see type information
If your ESLint fails due to some
.fml
andtypescript-eslint
related errors, refer to this troubleshooting section for potential fixes.
Next, create your first form component with FormML Model.
1 - Install FormML React bindings
npm install @formml/react --save
2 - Create a SignUpForm.tsx
file then display your first field:
import { Field, ErrorMessage, useFormML } from '@formml/react'
// import `.fml` directly and enjoy real-time type checking
import SignUp from './sign-up.fml'
export default function App() {
const { $form, FormML, handleSubmit } = useFormML(SignUp)
// "handleSubmit" will validate inputs before invoking real "onSubmit"
const onSubmit = handleSubmit(data => console.log(data))
return (
{/* "FormML" - a context provider */}
<FormML>
<form onSubmit={onSubmit}>
<label>Name</label>
{/* "Field" - a smart input component that binds to FormML Model field via `$form.name` */}
<Field $bind={$form.name} />
{/* "ErrorMessage" - a helper component that displays error message for the field */}
<ErrorMessage $bind={$form.name} as="span" />
<button>Submit</button>
</form>
</FormML>
)
}
Congratulations! You've just created your first FormML form. Integrate it into your app and run vite dev
to see the result now!
Learn more details by expanding below sections.
Understanding $form.name
and field indexes: How do they enable the next-level type safety?
We call $form.name
above a field index. It's a tool for referencing a FormML Model field in JavaScript, and is typically passed as the $bind
prop to components like Field
and ErrorMessage
.
Unlike string-based indexing approaches (used by Formik, React Hook Form, etc.), field indexes are generated from the FormML Model (with real-time type information provided through the TS plugin), enabling truly next-level type safety.
This next-level type safety manifests in three ways:
1 - Type-Based Completion
$form
is known as the index root - think of it as an index pointing to the entire form, making it the root of all child indexes.
Crucially, it's fully typed, so when you access its children via dot notation, VSCode automatically lists all possible child indexes.
This feature will also apply to planned FormML Model composite types, providing autocompletion when writing nested paths like $form.parent.child
.
2 - Field-Level Type Checking
$form.name
has type TextIndex
because it's defined as text name
in the FormML Model.
Therefore, if you have a custom DatePicker
component (introduced in the next section) that only accepts datetime
fields, $form.name
cannot be passed to it:
function DatePicker(props: { $bind: DatetimeIndex }) {
...
}
<DatePicker $bind={$form.name} /> {/* TS Error: "$form.name" is not of type DatetimeIndex */}
3 - Enabling Truly Reusable Form Components
This is where field indexes truly shine!
Field indexes provide an interface layer that decouples specific form content from generic form components, making components genuinely reusable.
For example, the DatePicker
component above depends on the DatetimeIndex
type rather than any specific form, so it can be reused across different scenarios - like birthday
in a sign-up form, dueDate
in a todo-list form, etc.
🎁 Planned FormML Model composite types will take reusability to new heights:
FormML Models will be able to group related fields into new composite types. Meanwhile generic components can also declare which fields they depend on.
Type compatibility checks are then left entirely to TypeScript.
Creating custom field components with useField
hook
Let's create a custom DatePicker
component mentioned above using the useField
hook:
function DatePicker(props: { $bind: DatetimeIndex }) {
const { field, meta } = useField(props.$bind)
return (
<>
<input type="datetime-local" {...field} />
{/* Includes `name`, `value`, `onChange`, `onBlur` */}
{meta.error && <span>{meta.error.message}</span>}
{meta.touched && <span>Ta-Da!</span>}
</>
)
}
📝 For more details about
useField
API, please refer to API Reference.
FormML implements a reactivity system based on @vue/reactivity. When form state updates occur, FormML ensures components only re-render when their specifically watched states change.
FormML is full-stack - you can use the same FormML Model to validate form submissions on the server side.
Before starting, make sure you've completed the bundler and TypeScript setup as described in the previous Create UI section.
1 - Install @formml/core
npm install @formml/core --save
2 - Use the parse
function to validate and transform data (Express.js example)
import express from 'express'
import { parse } from '@formml/core'
import SignUp from './sign-up.fml'
const app = express()
app.post('/sign-up', (req, res) => {
// Assuming form data is submitted as JSON
// Validate & parse plain object into rich object
const data = parse(req.body, SignUp)
res.status(201).end()
})
app.listen(3000, () => {
console.log('Server is running on port 3000')
})
Each primitive type in FormML Model has its corresponding JavaScript type. The parse
function will first validate the data according to the FormML Model definition, then convert plain data to rich types.
const data = {
name: 'John',
email: 'john@example.com',
password: 'password',
birthday: '1999-12-31T00:00:00.000Z',
}
const result = parse(data, SignUp) // Converts string => Date
// result is:
// {
// name: "John",
// email: "john@example.com",
// password: "password",
// birthday: Date("1999-12-31T00:00:00.000Z"),
// }
If validation fails, the parse
function will throw an error.
📝 For more information about the
parse
function, please refer to the API Reference.
FormML also provides
safeParse
andvalidate
functions for different scenarios. See the API Reference for details.
FormML Model refers to the .fml
file(s) that define a form's structure, types, and logic with FormML DSL.
For basic knowledge and how to create a new FormML Model, please refer to the Create Your First FormML Model section.
Every FormML Model starts with a form
block. Each model can only contain one form
block as its unique entry point.
form [name] {
...
[fields]
...
}
[name]
: A unique 6D40 name for the form. Can be ASCII letters, numbers, and underscores, but cannot start with a number. (Use pascal case conventionally)[fields]
: One or more field definitions
Defines a form field inside a form
block.
@[annotations]
[type] [name]
@[annotations]
: Zero or more annotations[type]
: The type of the field. Can be a primitive type or a composite type (WIP).[name]
: The unique name of the field. Can be ASCII letters, numbers, and underscores, but cannot start with a number. (Use camel case conventionally)
Represents a piece of textual content in any language (e.g., a name, email, or bio).
Runtime JavaScript type:
string
Represents a general-purpose number value (e.g., a quantity, age, or score).
⚠️ It follows the IEEE 754 floating-point representation under the hood. You may encounter precision issues when using it to represent currency or other high precision values.For that purpose, refer to the
decimal
type instead.
Runtime JavaScript type:
number
Represents a boolean (an either/or choice) value (e.g., true/false, yes/no).
Runtime JavaScript type:
boolean
Represents a decimal number value that won't lose precision on arithmetic operations (except for division). It's suitable for representing currency or other high precision values (e.g., a price, tax, or payment).
For general number scenarios that don't require high precision, use
num
type instead.
Runtime JavaScript type:
BigNumber
Represents a date, time, or date & time value.
Runtime JavaScript type:
Date
WIP
Annotations are optional modifiers that can be added to a field to enhance its behavior. Typically they are used for further validation beyond basic field type checks.
@[name]([arguments])
[name]
: The name of the annotation. Refer to the Annotation Reference for the full list.[arguments]
: Zero or more arguments passed to the annotation. See examples below for more details.
You can omit optional parameters.
@required() // omit optional param `message`
text name
If no arguments, you can also omit the parentheses.
@required
text name
You can pass arguments namelessly,
@maxLength(10, "Up to 10 characters")
text name
...or named-ly,
@maxLength(length: 10, message: "Up to 10 characters")
text name
...with any order.
@maxLength(message: "Up to 10 characters", length: 10)
text name
Of course you can mix them together,
@maxLength(10, message: "Up to 10 characters")
text name
...but remember, named arguments can only come after all positional arguments.
@maxLength(length: 10, "Up to 10 characters") // Error: Named argument can only appear after all positional arguments.
text name
All available annotations are defined in annotations.d.fml
. You can click the link below and cmd/ctrl + F
to search annotations.
Only epics are listed
- Basic forms
- Field calculations - JavaScript
- Composite field types
- Field calculations - Excel-like formula
- Synchronization (infra for auto-saving)
- Code blocks in DSL
I explained the motivation of FormML in detail in blog Beyond Basic Forms: Why Enterprise-Level Forms Remain a Challenge.
There are some comparisons with awesome tools in the ecosystem like React Hook Form.
If you are still confused, welcome to new a discussion in this repo.
When implementing type support for FormML DSL, I had two choices with different pros and cons:
- Code generation: Like Prisma, create a CLI to manually or semi-automatically generate type files in the filesystem.
- TypeScript language service plugin: Leverage TypeScript's native capabilities to provide real-time type support in the editor.
Neither solution is perfect. For example, the current approach (option 2) cannot support type checking in tsc
(see Using tsc
to check FormML Model types).
However, code generation has some significant drawbacks I couldn't accept:
- Whether visible or not, it always shits in your filesystem - managing these files has costs, both for users and FormML itself.
- It brings additional mental burden - requiring users to switch contexts (their code vs generated code).
- It adds complexity to CI/CD - extra build steps, more dependencies.
- Real-time updates require extra background process, or you have to do it by hands.
So I chose option 2 without much hesitation.
To be honest, while the ts-plugin has worked well so far, I'm still not entirely confident in this approach. This is mainly due to the TypeScript team's inaction on the plugin system (it has minimal documentation, limited functionality, and more importantly, the team doesn't seem very interested in it).
The good news is that FormML can always switch to option 1 in the future if we encounter insurmountable issues, since this is just one minor aspect of FormML. 😉
Due to the limitation of TypeScript language service plugin, it's impossible to provide type infos of .fml
files when running tsc
cli.
That is, if you rely on tsc
(directly or indirectly) to check types (mostly in CI/CD), you may get errors like:
error TS2307: Cannot find module 'xxx.fml' or its corresponding type declarations.
A mitigation is to create a .d.ts
file to basically skip all type checks for .fml
files, like:
// formml.d.ts
declare module '*.fml' {
const model: any
export default model
}
Place it anywhere in your project, and ensure it's included by your tsconfig.json
.
Now you can run tsc
without errors.
To resolve this issue completely, I plan to create a cli with command like formml check
to replace tsc --noEmit
for type checking.
If you are using "Linting with Type Information" feature of typescript-eslint
, it may fail due to typescript-eslint
cannot resolve .fml
file types correctly, even though TypeScript itself can.
To fix this, please ensure parserOptions.project: true
is set in your ESLint config (more details here). This enables typescript-eslint
to have the completely same type information as TypeScript.
Follow me on socials to get the latest updates about FormML: