Zero-dependency, schema-based form validation with a chainable TypeScript API. Optional adapters for React, Vue 3, and Svelte ship as separate entry points — tree-shake out whatever you don't use.
- Zero runtime dependencies — pure TypeScript core
- Dual ESM + CJS output — works in every modern bundler
- Full type inference —
InferSchema<typeof schema>gives you your form's type for free - Async rules —
custom()acceptsasyncfunctions (e.g. API availability checks) - Auto-coercion — number fields accept
"24", boolean fields accept"true"/"false" - Per-field validation —
validateField()foronChange/onBlurlive feedback - Framework adapters — React hook, Vue 3 composable, Svelte store (all optional)
npm install form-forgeFramework adapters have no extra install — they are already bundled. Add the relevant framework as a peer dependency if it isn't already in your project:
# React
npm install react
# Vue
npm install vue
# Svelte
npm install svelteimport { forge, f } from 'form-forge';
const validator = forge({
email: f.string().email().required(),
password: f.string().min(8).required(),
age: f.number().min(18).integer(),
terms: f.boolean().isTrue('Must accept terms').required(),
role: f.string().oneOf(['admin', 'user']).required(),
site: f.string().url(),
handle: f.string().matches(/^@/).trim().required(),
username: f.string().custom(async val => val !== 'taken' || 'Username taken').required(),
});
const result = await validator.validate({
email: 'user@example.com',
password: 'secret123',
age: '25', // strings are auto-coerced for number fields
terms: true,
role: 'admin',
handle: ' @cool ', // trimmed to '@cool'
username: 'free',
});
if (result.success) {
console.log(result.data);
// { email: string; password: string; terms: boolean; role: string; handle: string; username: string; age?: number; site?: string }
} else {
console.log(result.errors);
// [{ field: 'email', message: '...', rule: 'email' }, ...]
}Compiles a schema into a reusable Validator.
const validator = forge({
name: f.string().required(),
});Validates the entire form object. All fields run in parallel. Returns:
// success
{ success: true, data: T, errors: [] }
// failure
{ success: false, data: null, errors: [{ field, message, rule }] }Validates a single field — ideal for onChange/onBlur handlers.
const { valid, error } = await validator.validateField('email', inputValue);| Method | Description |
|---|---|
.required() |
Fail if empty / missing |
.min(n, msg?) |
Minimum character length |
.max(n, msg?) |
Maximum character length |
.email(msg?) |
Valid email format |
.url(msg?) |
Valid http(s):// URL |
.matches(regex, msg?) |
Must match pattern |
.oneOf(values, msg?) |
Must be one of the listed strings |
.trim() |
Strip whitespace; trimmed value is returned in data |
.custom(fn, key?) |
Sync or async rule (true = pass, string = error message) |
String inputs are auto-coerced ("24" → 24).
| Method | Description |
|---|---|
.required() |
Fail if missing |
.min(n, msg?) |
Minimum value (inclusive) |
.max(n, msg?) |
Maximum value (inclusive) |
.integer(msg?) |
Must be a whole number |
.positive(msg?) |
Must be > 0 |
.custom(fn, key?) |
Sync or async custom rule |
String inputs "true" / "false" are auto-coerced.
| Method | Description |
|---|---|
.required() |
Fail if missing |
.isTrue(msg?) |
Must be strictly true (checkbox "must agree") |
.isFalse(msg?) |
Must be strictly false |
.custom(fn, key?) |
Sync or async custom rule |
Derive your form's TypeScript type directly from the schema — no duplicate type declarations.
import { type InferSchema } from 'form-forge';
const schema = {
email: f.string().email().required(),
age: f.number().min(18),
};
type FormData = InferSchema<typeof schema>;
// { email: string; age?: number }Required fields (.required()) are non-optional; everything else is T | undefined.
import { useForge } from 'form-forge/react';
import { f } from 'form-forge';
const schema = {
email: f.string().email().required(),
password: f.string().min(8).required(),
};
function LoginForm() {
const {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset,
} = useForge(schema, {
initialValues: { email: '', password: '' },
validateOnBlur: true, // default
validateOnChange: false, // default
});
return (
<form onSubmit={handleSubmit(async data => {
await loginAPI(data);
})}>
<input
value={values.email ?? ''}
onChange={e => handleChange('email', e.target.value)}
onBlur={e => handleBlur('email', e.target.value)}
/>
{touched.email && errors.email && <span>{errors.email}</span>}
<input
type="password"
value={values.password ?? ''}
onChange={e => handleChange('password', e.target.value)}
onBlur={e => handleBlur('password', e.target.value)}
/>
{touched.password && errors.password && <span>{errors.password}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in…' : 'Login'}
</button>
<button type="button" onClick={reset}>Reset</button>
</form>
);
}<script setup lang="ts">
import { useForge } from 'form-forge/vue';
import { f } from 'form-forge';
const schema = {
email: f.string().email().required(),
password: f.string().min(8).required(),
};
const { values, errors, touched, isSubmitting, handleChange, handleBlur, handleSubmit, reset } =
useForge(schema, { initialValues: { email: '', password: '' } });
</script>
<template>
<form @submit.prevent="handleSubmit(async data => await loginAPI(data))()">
<input
v-model="values.email"
@change="handleChange('email', values.email)"
@blur="handleBlur('email', values.email)"
/>
<span v-if="touched.email && errors.email">{{ errors.email }}</span>
<input
type="password"
v-model="values.password"
@change="handleChange('password', values.password)"
@blur="handleBlur('password', values.password)"
/>
<span v-if="touched.password && errors.password">{{ errors.password }}</span>
<button :disabled="isSubmitting">{{ isSubmitting ? 'Logging in…' : 'Login' }}</button>
<button type="button" @click="reset">Reset</button>
</form>
</template><script lang="ts">
import { forgeStore } from 'form-forge/svelte';
import { f } from 'form-forge';
const schema = {
email: f.string().email().required(),
password: f.string().min(8).required(),
};
const form = forgeStore(schema, {
initialValues: { email: '', password: '' },
});
</script>
<form on:submit|preventDefault={form.handleSubmit(async data => await loginAPI(data))}>
<input
bind:value={$form.values.email}
on:change={() => form.handleChange('email', $form.values.email)}
on:blur={() => form.handleBlur('email', $form.values.email)}
/>
{#if $form.touched.email && $form.errors.email}
<span>{$form.errors.email}</span>
{/if}
<input
type="password"
bind:value={$form.values.password}
on:change={() => form.handleChange('password', $form.values.password)}
on:blur={() => form.handleBlur('password', $form.values.password)}
/>
{#if $form.touched.password && $form.errors.password}
<span>{$form.errors.password}</span>
{/if}
<button disabled={$form.isSubmitting}>
{$form.isSubmitting ? 'Logging in…' : 'Login'}
</button>
<button type="button" on:click={form.reset}>Reset</button>
</form>Pull requests are welcome. Run npm test to verify all 19 tests pass before
submitting. For larger changes, open an issue first.
MIT — see LICENSE.