Form BuilderLast updated on
Last updated on
Create Form artifacts with fields, parties, and signing flows
Create Form artifacts for interactive forms with fields, signing parties, and document annexes.
Examples
Creating a form
// Object pattern
const form = p.form({
name: 'lease-agreement',
version: '1.0.0',
title: 'Residential Lease Agreement',
fields: {
tenantName: { type: 'text', label: 'Tenant Name', required: true },
moveInDate: { type: 'date', label: 'Move-in Date' },
monthlyRent: { type: 'money', label: 'Monthly Rent', min: 0 }
},
parties: {
tenant: { label: 'Tenant', partyType: 'person', signature: { required: true } },
landlord: { label: 'Landlord', signature: { required: true } }
},
annexes: {
photoId: { title: 'Photo ID', required: true }
},
layers: {
html: { kind: 'inline', mimeType: 'text/html', text: '<p>Tenant: {{fields.tenantName}}</p>' }
},
defaultLayer: 'html'
})
// Builder pattern
const form = p.form()
.name('lease-agreement')
.version('1.0.0')
.title('Residential Lease Agreement')
.field('tenantName', field.text().label('Tenant Name').required().build())
.field('moveInDate', field.date().label('Move-in Date').build())
.field('monthlyRent', field.money().label('Monthly Rent').min(0).build())
.party('tenant', party().label('Tenant').partyType('person')
.signature({ required: true }).build())
.party('landlord', party().label('Landlord')
.signature({ required: true }).build())
.annex('photoId', annex().title('Photo ID').required().build())
.inlineLayer('html', { mimeType: 'text/html', text: '<p>Tenant: {{fields.tenantName}}</p>' })
.defaultLayer('html')
.build()Adding instructions
// Inline instructions
const form = p.form({
name: 'w9-form',
instructions: { kind: 'inline', text: 'See IRS instructions for Form W-9...' },
agentInstructions: { kind: 'inline', text: 'Present fields in this order: name, TIN, address.' },
// ...fields, parties, layers
})
// File-backed instructions (builder pattern)
const form = p.form()
.name('w9-form')
.instructions({ kind: 'file', path: './w9-instructions.md', mimeType: 'text/markdown' })
.agentInstructions({ kind: 'inline', text: 'Present fields in this order: name, TIN, address.' })
.build()Loading from external data
// Parse and validate unknown input (throws on error)
const form = p.form.from(jsonData)
// Safe parsing (returns result object)
const result = p.form.safeFrom(jsonData)
if (result.success) {
const form = result.data
}Form Lifecycle
Forms follow a three-phase lifecycle: Draft → Signable → Executed.
// 1. Fill form to create DraftForm (mutable data)
const draft = form.fill({
fields: { tenantName: 'John Doe', moveInDate: '2024-02-01' },
annexes: { photoId: { name: 'photo-id.pdf', mimeType: 'application/pdf' } },
parties: {
tenant: { id: 'tenant-0', name: 'John Doe' },
landlord: { id: 'landlord-0', name: 'ABC Property LLC', legalName: 'ABC Property LLC' }
}
})
// 2. Configure signers and signatories
const ready = draft
.addSigner('john', {
person: { name: 'John Doe' },
adopted: { signature: { image: 'data:...', method: 'drawn' } }
})
.addSignatory('tenant', 'tenant-0', { signerId: 'john' })
.addSigner('abc-property', {
person: { name: 'Bob Manager' },
adopted: { signature: { image: 'data:...', method: 'drawn' } }
})
.addSignatory('landlord', 'landlord-0', { signerId: 'abc-property', capacity: 'Property manager' })
// 3. Prepare for signing (SignableForm - data frozen)
const signable = ready.prepareForSigning()
// 4. Capture signatures
const signed = signable
.captureSignature('tenant', 'tenant-0', 'john', 'final-sig')
.captureSignature('landlord', 'landlord-0', 'abc-property', 'final-sig')
// 5. Finalize (ExecutedForm - fully frozen)
const executed = signed.finalize()
// 6. Render the executed form
const output = await executed.render()API
Object Pattern
p.form(input: FormInput, options?: ArtifactInstanceOptions): FormInstanceoptions.resolver reads the bytes of any file-backed layer this form
declares. It's bound once here, at construction, and every instance derived
from the form — every fill, mutator, and clone — carries it; render()
itself takes no resolver. See ArtifactInstanceOptions.
Parameters
Returns
Returns a FormInstance with the following properties and methods:
Builder Pattern
Chain methods to build a form incrementally:
All methods return FormBuilder and are chainable.
Static Methods
Parse forms from unknown data sources. Both accept an
ArtifactInstanceOptions to bind a
resolver for any file-backed layer:
Data Validation
FormInstance provides Zod-style methods for validating data:
// Throws FormValidationError if validation fails
const validated = form.parseData({ fields: { tenantName: 'John' } })
// Returns result object
const result = form.safeParseData({ fields: { tenantName: 'John' } })
if (result.success) {
console.log(result.data)
} else {
console.error(result.errors)
}Progressive Validation
Use progressive validators to validate one user answer at a time before calling fill().
Return shape
All progressive validators return:
type ProgressiveValidationResult<T> =
| { success: true; value: T; errors: null }
| { success: false; value: null; errors: ValidationError[] }Field input validation
const species = form.validateFieldInput({
fieldPath: 'species',
value: 'cat',
})
const nested = form.validateFieldInput({
fieldPath: ['profile', 'nickname'],
value: 'Toby',
})
const partialFields = form.validateFieldsPatch({
species: 'dog',
weight: 20,
})Party input validation
const tenantResult = form.validatePartyInput({
roleId: 'tenant',
value: { name: 'John Smith' },
})
// For multi-party roles, pass an index.
const witnessResult = form.validatePartyInput({
roleId: 'witness',
index: 1,
value: { name: 'Jane Witness' },
})
const partiesPatch = form.validatePartiesPatch({
tenant: { name: 'John Smith' },
landlord: { name: 'Acme Inc', legalName: 'Acme Inc' },
})validatePartyInput normalizes runtime IDs using {roleId}-{index} (for example tenant-0).
Annex input validation
const annexResult = form.validateAnnexInput({
annexId: 'photoId',
value: { name: 'photo-id.pdf', mimeType: 'application/pdf' },
})
const annexPatch = form.validateAnnexesPatch({
photoId: { name: 'photo-id.pdf', mimeType: 'application/pdf' },
})Every annex value must be an Attachment: { name, mimeType, checksum? } with no other keys. A value of any other shape fails with an error under annexes.<annexId>. fill, update, and setAnnex apply the same check.
When allowAdditionalAnnexes is false, unknown annex keys are rejected. When it is true, an unknown key is accepted and its value must still be an Attachment. The progressive validators and full payload validation (validateFormData, fill) apply the same rule.
Incremental Filling
Use fill to create a DraftForm from partial (or empty) data, then progressively fill it with update. This is the recommended pattern for AI agents and step-by-step UIs that collect data one field at a time.
Creating a draft from partial data
// Start with empty data
const draft = form.fill()
// Start with some known values
const draft = form.fill({
fields: { tenantName: 'John Doe' },
parties: { tenant: { id: 'tenant-0', name: 'John Doe' } }
})
// Safe variant (returns result object)
const result = form.safeFill({ fields: { tenantName: 'John' } })
if (result.success) {
const draft = result.data
}Updating a draft progressively
// Merge additional data into the draft
const updated = draft.update({
fields: { moveInDate: '2024-06-01' }
})
// Safe variant
const result = draft.safeUpdate({ fields: { monthlyRent: { amount: 1500, currency: 'USD' } } })
if (result.success) {
const updated = result.data
}Querying fill state
getFillState() returns a snapshot of progress, including what's open, what's blocked behind conditional visibility, and what to ask next. See Fill State for full type reference.
const state = draft.getFillState()
// Progress
state.summary.completionPercent // 0–100
state.summary.requiredRemaining // fields/parties still needed
// What to ask next
state.next // FillTarget | null — first candidate
state.candidates // FillTarget[] — all visible, unfilled targets
// Items blocked by conditional visibility
state.blocked // FillItemState[] — will unblock when dependencies are filled
// Shorthand helpers
const next = draft.getNextFillTarget()
const all = draft.getAvailableFillTargets()Acceptance and readiness
fill() and update() validate every supplied value. Omitted required values remain open so a caller can build a draft across turns. safeFill() succeeds when that supplied patch is accepted. Use draft.isValid() or draft.validate() to assess full readiness, including rules. Rendering a draft is allowed; signing, sealing, and final bundle operations require readiness.
Extracting data from a filled PDF
form.extract(pdf, options?) reads a filled PDF back into form data through a PDF layer's bindings. It reads the PDF's AcroForm field values and maps each one back to its artifact path. The PDF is not changed.
import { readFileSync } from 'node:fs'
import { w9 } from '@paradoc/essentials'
const { layer, data, report } = await w9.extract(readFileSync('completed-w9.pdf'))
// Extraction does not validate. The normal fill path does.
const result = w9.safeFill(data)
if (result.success) {
const state = result.data.getFillState() // required values the PDF did not hold are open
}With no layer option, extract uses the form's only PDF layer. A form with several PDF layers, such as the copies of a 1099, needs { layer: 'pdfCopyB' }. Pass formatter when the PDF was filled with a formatter other than the default.
The result has three parts:
| Part | Description |
|---|---|
layer | The PDF layer the PDF was read against |
data | A { fields, parties? } payload with only the values read back exactly |
report.entries | One entry for each binding target: { path, status, sources, reason? }. sources lists the PDF fields that carry the path with their raw values. |
report.unbound | PDF fields that hold a value no binding covers |
Each entry's status is one of:
recovered: the value was read back exactly and is indata.empty: the PDF fields are empty. An unchecked box counts as empty, because it cannot be told apart from an unanswered one.not_recoverable: the value cannot be mapped back. A combined binding ('city,state,zipCode') writes several values into one box, and extraction does not guess how to split it; the raw text is insources.unparseable: the text does not parse into the field's type, or two PDF fields that carry the path disagree.
Direct bindings, enum checkboxes, radio groups, dropdowns, and split fields are reversed exactly. Text is parsed back into the field's type: the formatter's own output for enums, numbers, percentages, dates, and money, as well as plain values a person might type, such as 1234.50 or a date in the locale's numeric order. A money amount needs its currency symbol, or a binding to its amount alone. On a layer that prints money without a symbol (format: { money: { currencyDisplay: 'none' } }), the amount is read in the currency the field declares; a field with no currency is unparseable. A structured value written into one box, such as a whole address, is not_recoverable; bind its parts to recover them.
A partial value from a combined box stays partial. On the W-9, the city, state, and ZIP code share one box, so data has mailingAddress.line1 only, and safeFill names the missing parts.
extract throws a PdfExtractionError whose code names the failure: no_form_fields (a flattened or scanned PDF, which needs the hosted Paradoc extraction service), not_matching (the PDF has no field for a binding key, so it is not this form), malformed_pdf (not a PDF, truncated, or unreadable), no_pdf_layer, layer_required, layer_not_found, not_pdf_layer, or unknown_bindings_source (the layer's bindingsFrom names no layer). An encrypted PDF throws PdfEncryptedError, whose code is encrypted_pdf.
The CLI's paradoc data extract and the AI extract tool return the same result.
Lifecycle Types
The fill() method returns a DraftForm that follows the form lifecycle:
| Type | Phase | Data | Signatures |
|---|---|---|---|
DraftForm | Draft | Mutable | Configure signers/signatories |
SignableForm | Signable | Frozen | Capture signatures |
ExecutedForm | Executed | Frozen | Frozen (complete) |
DraftForm
Created by form.fill(). Mutable data and signer configuration.
SignableForm
Created by draft.prepareForSigning() or draft.seal(). Frozen data, signature capture allowed.
ExecutedForm
Created by signable.finalize(). Fully frozen, ready for archival.
Render options
form.render() takes RenderOptions. A filled form's render() takes RuntimeFormRenderOptions, which is the same without data, because the form already holds it.
import { reactLayerRenderers } from '@paradoc/react-pdf'
const pdf = await draft.render({
layer: 'composition',
renderers: reactLayerRenderers({ components: { 'purchase-order.tsx': PurchaseOrder } }),
})Seal options
seal() renders the target layer, resolves its signature fields, flattens the PDF, and records its hash. A PDF layer seals locally. Any other layer needs an adapter that converts it to PDF, unless a renderer registered in renderers writes the PDF itself, as the React renderer does.
The seal picks its renderer in this order:
- A renderer registered for a React layer's MIME type. It receives the flow markers and writes the PDF, so it needs no
adapter. - Core's text renderer, whenever the layer places a slot in flow. Passing
rendereralongside flow placement throwsSealConfigError. - Otherwise
renderer, then therenderersentry for the layer's MIME type, then the built-in renderer.
An adapter only converts. It implements convert(request) and returns { pdf, signatureMap? }. Core still renders, flattens, and computes the SHA-256 hash itself. On a layer with signatures, core places every field from its slots and ignores an adapter's signatureMap. On a layer with no slots, the adapter's signatureMap is the signature map.
const signable = await draft
.addSigner('jane', { person: { name: 'Jane Doe' } })
.addSignatory('tenant', 'tenant-0', { signerId: 'jane' })
.seal()
signable.signatureMap // signature field coordinates
signable.canonicalPdfHash // 'sha256:...'Related
- field - Field definitions
- annex - Annex slot definitions
- party - Party role definitions
- fill-state - Fill state types and progressive filling
- Form Schema