@harnessio/forms
v1.2.0
Published
Harness Forms Library
Keywords
Readme
@harnessio/forms
A type-safe, configuration-driven form library for React applications.
Overview
@harnessio/forms provides a declarative approach to building forms using configuration schemas. The library generates forms from type-safe definitions, handles validation, and manages form state through React Hook Form integration.
Key Features:
- 🎯 Type-safe form definitions with TypeScript generics
- 📋 Configuration-driven form generation
- ✅ Built-in validation with Zod integration
- 🔢 Tuple support for fixed-position arrays
- 🎨 Flexible input component system
- 🔄 Conditional visibility and dynamic forms
Core Principles
- Forms are generated from configuration
- Each input defines its own configuration interface
- Validation schemas are part of input configuration
- Type safety through generic type parameters
- Extensible through custom input components
Type System
The library uses generic type parameters for type safety:
IInputDefinition<TConfig, TValue, TInputType>
TConfig- Input-specific configuration typeTValue- Value type for the inputTInputType- String literal type for input type
InputProps<TValue, TConfig>
TValue- Value type for the inputTConfig- Input-specific configuration type
Quick Start
1. Define Input Types
Each input has a unique type identifier:
export enum InputType {
text = 'text',
number = 'number',
checkbox = 'checkbox',
array = 'array',
list = 'list'
// ... more types
}2. Create Input Components
Define input configuration and component:
import { InputComponent, InputProps, useController } from '@harnessio/forms'
// 1. Define input config type
export interface TextInputConfig {
placeholder?: string
}
// 2. Define value type
export type TextInputValueType = string
// 3. Create component with typed props
function TextInputInternal(
props: InputProps<TextInputValueType, TextInputConfig>
): JSX.Element {
const { readonly, path, input } = props
const { label, required, inputConfig } = input
const { field } = useController({ name: path })
return (
<>
<label>{label}</label>
<input
placeholder={inputConfig?.placeholder}
{...field}
disabled={readonly}
/>
</>
)
}
// 4. Register as InputComponent
export class TextInput extends InputComponent<TextInputValueType, TextInputConfig> {
public internalType = InputType.text
renderComponent(props: InputProps<TextInputValueType, TextInputConfig>): JSX.Element {
return <TextInputInternal {...props} />
}
}See examples: playground/src/implementation/inputs/
3. Register Inputs
Use InputFactory to register your input components:
import { InputFactory } from '@harnessio/forms'
import { NumberInput } from './inputs/number-input'
import { TextInput } from './inputs/text-input'
const inputComponentFactory = new InputFactory()
inputComponentFactory.registerComponent(new TextInput())
inputComponentFactory.registerComponent(new NumberInput())
export default inputComponentFactory4. Define Form Schema
Create a type-safe form definition using IFormDefinition:
import { z } from 'zod'
import { IFormDefinition } from '@harnessio/forms'
// Basic form without custom input configs
export const basicFormDefinition: IFormDefinition = {
inputs: [
{
inputType: 'text',
path: 'name',
label: 'Name',
required: true,
validation: {
schema: z.string().min(3, 'Name must be at least 3 characters')
}
},
{
inputType: 'number',
path: 'age',
label: 'Age',
validation: {
schema: z.coerce.number().min(18, 'Must be 18 or older')
}
}
]
}With custom input configs for type safety:
// 1. Define config types for each custom input
export interface ListInputConfig {
inputType: 'list'
inputConfig: {
inputs: IInputDefinition[]
layout?: 'grid' | 'default'
}
}
export interface TextInputConfig {
inputType: 'text'
inputConfig?: {
placeholder?: string
}
}
// 2. Create union type
export type InputConfigType = ListInputConfig | TextInputConfig
// 3. Use union type for type-safe form definition
export const formDefinition: IFormDefinition<InputConfigType> = {
inputs: [
{
inputType: 'text',
path: 'username',
label: 'Username',
inputConfig: {
placeholder: 'Enter username' // ✅ Type-safe
}
},
{
inputType: 'list',
path: 'items',
label: 'Items',
inputConfig: {
inputs: [...], // ✅ Type-safe
layout: 'grid'
}
}
]
}5. Render Form
Use RootForm and RenderForm components:
import { RootForm, RenderForm, useZodValidationResolver } from '@harnessio/forms'
import { inputComponentFactory } from './factory'
import { formDefinition } from './form-definition'
function MyForm() {
const resolver = useZodValidationResolver(formDefinition)
const handleSubmit = (values: AnyFormValue) => {
console.log('Form values:', values)
}
return (
<RootForm
defaultValues={{}}
onSubmit={handleSubmit}
resolver={resolver}
>
{rootForm => (
<>
<RenderForm
factory={inputComponentFactory}
inputs={formDefinition}
/>
<button onClick={() => rootForm.submitForm()}>
Submit
</button>
</>
)}
</RootForm>
)
}Tuple Support (Fixed-Position Arrays)
The library supports tuple paths using numeric indices for fixed-position arrays where each position can have its own schema.
When to Use Tuples
✅ Use tuples for:
- Fixed number of elements (e.g.,
[x, y]coordinates) - Position-specific schemas (e.g.,
[primary, backup]servers) - Elements that shouldn't be added/removed dynamically
❌ Use array or list input types for:
- Variable number of items
- Add/remove functionality
- Same schema for all items
Tuple Path Syntax
// Coordinates: [10, 20]
{
inputs: [
{ inputType: 'number', path: 'coordinates.0', label: 'X' },
{ inputType: 'number', path: 'coordinates.1', label: 'Y' }
]
}
// Nested objects: [{ name: 'Primary', url: '...' }, { name: 'Backup', url: '...' }]
{
inputs: [
{ inputType: 'text', path: 'servers.0.name', label: 'Primary Server' },
{ inputType: 'text', path: 'servers.0.url', validation: { schema: z.string().url() } },
{ inputType: 'text', path: 'servers.1.name', label: 'Backup Server' },
{ inputType: 'text', path: 'servers.1.url', validation: { schema: z.string().url() } }
]
}
// Different schemas per position
{
inputs: [
{ inputType: 'text', path: 'owners.0.email', validation: { schema: z.string().email() } },
{ inputType: 'select', path: 'owners.1.role', inputConfig: { options: [...] } }
]
}
// Sparse indices: ['Critical', null, null, null, null, 'Low']
{
inputs: [
{ inputType: 'text', path: 'priorities.0', label: 'High Priority' },
{ inputType: 'text', path: 'priorities.5', label: 'Low Priority' }
]
}Example: See playground/src/examples/tuple-example/
Validation
The library integrates with Zod for schema validation.
Per-Input Validation
Define validation schemas directly in input definitions:
Static validation:
{
inputType: 'text',
path: 'email',
label: 'Email',
required: true,
validation: {
schema: z.string().email('Invalid email format')
}
}Dynamic validation (depends on other form values):
{
inputType: 'text',
path: 'password',
label: 'Password',
required: true
},
{
inputType: 'text',
path: 'confirmPassword',
label: 'Confirm Password',
required: true,
validation: {
schema: (values) =>
z.string().refine(
(val) => val === values.password,
{ message: 'Passwords must match' }
)
}
}Nested required fields: required is enforced even when a parent object in the path is missing from the form values. For example, spec.connectorRef with required: true fails when the values have no spec at all. Objects whose children are all optional still pass when missing. Nested inputs are also validated when their parent input has its own required or validation rule; the nested inputs are checked once the parent's rule passes.
Global Validation Configuration
Configure validation globally using useZodValidationResolver:
const resolver = useZodValidationResolver(
formDefinition,
{
// Custom required message for all inputs
requiredMessage: 'This field is required',
// Custom required message per input type
requiredMessagePerInput: {
text: 'Text field is required',
number: 'Please enter a number'
},
// Custom required schema per input type
requiredSchemaPerInput: {
text: z.string().min(1),
number: z.number(),
myCustomInput: z.custom(...)
},
// Global validation function
globalValidation: (value, input, metadata) => {
// Custom validation logic
return { continue: true }
}
},
metadata
)Validation resolution order for required fields:
requiredSchemaPerInput[inputType]requiredSchema- Built-in default validation
Additional Features
Conditional Visibility
Control input visibility based on form values:
{
inputType: 'select',
path: 'authType',
label: 'Authentication Type'
},
{
inputType: 'text',
path: 'apiToken',
label: 'API Token',
isVisible: (values) => values.authType === 'token'
}Default Values
Set default values using the default property or collectDefaultValues:
import { collectDefaultValues } from '@harnessio/forms'
// In input definition
{
inputType: 'text',
path: 'name',
default: 'John Doe'
}
// Collect all defaults from form definition
const defaultValues = collectDefaultValues(formDefinition)Value Transformers
Transform values between data model and form state:
{
inputType: 'text',
path: 'tags',
label: 'Tags',
// Transform incoming data (array) to form display (string)
inputTransform: (value: string[]) => ({
value: value.join(', ')
}),
// Transform form value (string) back to data model (array)
outputTransform: (value: string) => ({
value: value.split(',').map(s => s.trim())
})
}Transformer functions:
inputTransform- Converts data model → form state (runs when data is loaded)outputTransform- Converts form state → data model (runs on submit)- Both must return
{ value: any }orundefined - Can chain multiple transformers by providing an array
API Reference
Core Types
// Input definition with generic parameters
IInputDefinition<TConfig = unknown, TValue = unknown, TInputType extends string = string>
// Input component props
InputProps<TValue = unknown, TConfig = unknown>
// Form definition
IFormDefinition<TConfig = unknown>
// Value types
AnyFormValue // any form value typeMain Components
RootForm- Root form component with React Hook Form integrationRenderForm- Renders inputs from form definitionInputFactory- Registry for input componentsInputComponent<TValue, TConfig>- Base class for input components
Hooks
useZodValidationResolver(definition, config?, metadata?)- Creates Zod validation resolveruseController(options)- React Hook Form controller hook
Utilities
collectDefaultValues(definition)- Extracts default values from form definition
Examples
Complete working examples can be found in playground/src/examples/:
- Basic Example - Simple form with text and number inputs
- Tuple Example - Fixed-position arrays
- Array Example - Dynamic arrays
- List Example - Dynamic object lists
- Conditional Example - Conditional visibility
License
See the main repository license.
