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.
InferSchema<typeof schema> gives you your form's type for freecustom() accepts async functions (e.g. API availability checks)"24", boolean fields accept "true"/"false"validateField() for onChange/onBlur live feedbacknpm install form-forge
Framework 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 svelte
import { 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: '[email protected]',
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' }, ...]
}
forge(schema)Compiles a schema into a reusable Validator.
const validator = forge({
name: f.string().required(),
});
validator.validate(data)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 }] }
validator.validateField(field, value)Validates a single field — ideal for onChange/onBlur handlers.
const { valid, error } = await validator.validateField('email', inputValue);
f)f.string()| 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) |
f.number()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 |
f.boolean()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 |
InferSchema<S>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.