Build Your Own Bedrock · Part 15
Forms that parse, don't validate
The last wall between the outside world and your types is a text box. The usual approach: keep strings in state, run a validation schema on submit, and hope the object you post matches what the schema checked. Bedrock does to forms what it did to every other boundary — parsing. A field doesn't get checked; it gets converted into a domain type, on every keystroke, and the submit button reads the conversion's result.
A field is raw input + parser + memo
// Core/Data/Form/Field.ts
const fieldKey: unique symbol = Symbol()
export type Field<E, V, T> =
Opaque<FieldInternal<E, V, T>, typeof fieldKey, V>
export type FieldInternal<E, V, T> = {
value: V
parser: ParseDontValidateFn<E, V, T>
_memo: Maybe<Result.Result<E, T>>
}
export type ParseDontValidateFn<E, V, T> = (s: V) => Result.Result<E, T> Three type parameters: E the error union, V the raw input type (string, for a text box), T the parsed domain type. The field carries its own parser — the same createEmailE and createPasswordE smart constructors from Part 6, so a form field can't drift from the domain's definition of valid. _memo caches the last parse: null means "not parsed yet" (a pristine form shows no red), and a Result means the verdict is in. The whole record hides behind the Opaque wrapper from Part 5 — note the third argument, V: unwrap() yields the raw value, which is exactly what an <input> needs to render.
Parse on change, read the memo
// Core/Data/Form/Field.ts
export function changeAndParse<E, V, T>(
value: V,
f: Field<E, V, T>,
): Field<E, V, T> {
const fi = _internal(f)
return parse(_create({ ...fi, value }))
}
export function error<E, V, T>(f: Field<E, V, T>): Maybe<E> {
const fi = _internal(f)
return fi._memo == null ? null : Result.error(fi._memo)
}
export function value<E, V, T>(f: Field<E, V, T>): Maybe<T> {
const fi = _internal(f)
return fi._memo == null ? null : Result.value(fi._memo)
} Every operation returns a new Field — immutable, like everything else in the codebase. changeAndParse swaps the raw value in and re-runs the parser once; after that, error() and value() are free reads of the memo. The two accessors are the two sides of the Result: a field with a parse error has no value, a field with a value has no error, and an untouched field has neither. FieldString<E, T> (Core/Data/Form/FieldString.ts) is the one-line specialization pinning V = string.
Wiring it into state and actions
// Web/src/State/Login.ts
export function initLoginState(): LoginState {
return {
email: FieldString.init("", createEmailE),
password: FieldString.init("", createPasswordE),
loginResponse: RD.notAsked(),
}
}
// Web/src/Action/Login.ts
export function onChangeEmail(value: string): Action {
return (state) => {
const { email } = state.login
return [
_LoginState(state, {
email: FieldString.changeAndParse(value, email),
}),
cmd(),
]
}
} One action per field, each a pure state transition in the Part 14 loop: replace the field, no command. The parser assigned at init is the field's identity — nothing else about "what makes an email valid" exists in the frontend.
Submit is reading the memo
// Web/src/State/Login.ts
export function parseNotValidate(
loginState: LoginState,
): Maybe<LoginApi.BodyParams> {
const { email, password } = loginState
const emailM = FieldString.value(email)
const passwordM = FieldString.value(password)
return emailM == null || passwordM == null
? null
: { email: emailM, password: passwordM }
} The name is the doctrine. This function doesn't ask "is the form valid?" — it asks the fields for their parsed values and assembles the request body, or can't. When it succeeds, you're holding LoginApi.BodyParams with a real Email and a real Password — the same opaque types the contract's bodyDecoder re-checks server-side in Part 11. There is no gap where "validated" and "what gets sent" can disagree, because the sent value is the parse output.
The view renders the same truth
// Web/src/Page/Login.tsx
const loginParams = parseNotValidate(props.state.login)
// …
<InputText
value={email.unwrap()}
invalid={FieldString.error(email) != null}
type="email"
onChange={(value) => emit(LoginAction.onChangeEmail(value))}
/>
// …
<Button
label={isSubmitting ? "Submitting..." : "Submit"}
disabled={isSubmitting === true || loginParams == null}
onClick={() => {
if (isSubmitting == false && loginParams != null) {
emit(LoginAction.onSubmit(loginParams))
}
}}
/> unwrap() feeds the raw string back to the input; error() != null drives the red border; the button is disabled exactly when the params don't parse. The errors themselves are the same ErrorX unions the smart constructors return — ErrorPassword is "INVALID_LENGTH" | "MISSING_NUMBER" | …, and passwordErrors (the accumulate-everything variant from Part 6) exists precisely so a form can show all unmet rules at once instead of one per keystroke. Field, action, state, view: four layers, one definition of valid.
In your language
The recipe is portable to anything with immutable records and a Result type. In Kotlin, a data class Field<E, V, T> holding the raw value, a parse function, and a memoized Arrow Either — with a private constructor so only changeAndParse builds it. In Swift, a struct with a private var memo: Result<T, E>? and computed error/value properties. In Rust, a struct with private fields whose set() re-runs a FnMut(&str) -> Result<T, E>. In Python, a frozen dataclass replaced via dataclasses.replace, parsing into a NewType. Elm did it first: form libraries there are all "raw string + parser to domain type" — because parse, don't validate, is an Elm proverb before it's a TypeScript one.