docs: add general documentation improvements to README#32
docs: add general documentation improvements to README#32RJSonnenberg wants to merge 2 commits intomainfrom
Conversation
Add comprehensive documentation sections: - Installation: How to install the library - Quick Start: Minimal email validation example with key concepts - FAQ / Common Patterns: Guidance on refute vs dispute, failure composition, nested collections, mapInvalid usage, and async validation - Advanced Patterns: Detailed patterns for complex validation scenarios including: * Failure type composition across domains * Conditional field validation * Validating nested collections with indices * Using flattenProofs for nested validations * Cross-field validation * Smart constructors for validated types * Error transformation with mapInvalid These general documentation improvements complement the new features added in PR #29 and help users understand validation patterns and best practices.
ddcd6e6 to
bb53311
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds comprehensive documentation improvements to the README, including a new Installation section, a Quick Start guide, an FAQ / Common Patterns section with five Q&A entries, and an Advanced Patterns section with seven examples. It also makes minor corrections to existing documentation (clarifying a comment on disputeWith and updating the validateEach function signature description).
Changes:
- Added
InstallationandQuick Startsections at the top of the document, with new Table of Contents entries - Added ~600-line
FAQ / Common Patternssection coveringrefutevsdispute, failure composition, nested collections,mapInvalid, and async validation - Added ~500-line
Advanced Patternssection with seven detailed examples; made minor accuracy corrections to thedisputeWithcomment andvalidateEachdescription
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…rove validation logic
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let! _ = validation { | ||
| withValue (name, username) | ||
| disputeWithFact NameMatchesUsername (fun (n, u) -> | ||
| match n with | ||
| | Some nameVal -> Name.unwrap nameVal <> Username.unwrap u | ||
| | None -> true | ||
| ) | ||
| qed | ||
| } |
There was a problem hiding this comment.
The cross-field validation was changed from and! _ = to let! _ =, which diverges from the established pattern used in samples/GettingStarted/Types.fs (line 252) and samples/FSharp.Data.Validation.Samples/ComplexTypes.fs (line 64). The let! form runs the cross-field check sequentially — meaning it is skipped entirely if any preceding field validation was refuted (e.g., if name or username was None). The established and! _ = form using withValue vm runs in parallel with other field validations, ensuring cross-field checks are always evaluated alongside field-level checks. These are semantically different behaviors, and the README example no longer matches the pattern shown in the actual sample code.
There was a problem hiding this comment.
The example here is correct. The previous example only compares the values prior to validation. The current method will ensure the username and name do not match only if they are valid. That said, there may be cases where we are fine with performing that simplistic comparison. This particular example is designed to be a simplistic example.
@copilot Please revert this change to the "and!" method and simple, pre-validated name and username value comparison. Add a note to this example that directs them to part of the documentation where global validation is done on previously-validated fields or values.
| // Run async validations asynchronously as possible | ||
| let! emailExists = checkEmailExistsAsync email | ||
| let! usernameTaken = checkUsernameTakenAsync username | ||
|
|
There was a problem hiding this comment.
The comment "Run async validations asynchronously as possible" is misleading — the code immediately below uses sequential let! emailExists = ... followed by let! usernameTaken = ..., which runs sequentially, not in parallel. The pattern summary at line 1980 also states "Async validations can run in parallel for better performance", further reinforcing the incorrect claim. The comment should either say "sequentially" or the code should use Async.Parallel to demonstrate actual parallel execution.
| // Run async validations asynchronously as possible | |
| let! emailExists = checkEmailExistsAsync email | |
| let! usernameTaken = checkUsernameTakenAsync username | |
| // Run async validations in parallel for better performance | |
| let! results = | |
| [ checkEmailExistsAsync email | |
| checkUsernameTakenAsync username ] | |
| |> Async.Parallel | |
| let emailExists = results.[0] | |
| let usernameTaken = results.[1] |
| and! newPwd = | ||
| validation { | ||
| withField (fun () -> this.NewPassword) | ||
| refuteWith (isRequired Required) | ||
| refuteWith (fun p -> | ||
| if p = oldPwd then Error NewPasswordSameAsOld | ||
| else Ok p | ||
| ) | ||
| qed id | ||
| } | ||
|
|
||
| and! confirmPwd = | ||
| validation { | ||
| withField (fun () -> this.ConfirmNewPassword) | ||
| refuteWith (isRequired Required) | ||
| qed id | ||
| } | ||
|
|
||
| and! _ = | ||
| validation { | ||
| // Cross-field validation: compare old and new passwords | ||
| withValue (oldPwd, newPwd, confirmPwd) | ||
| disputeWithFact NewPasswordSameAsOld (fun (old, new_, _) -> new_ <> old) | ||
| disputeWithFact ConfirmationDoesNotMatch (fun (_, new_, confirm) -> new_ = confirm) | ||
| qed | ||
| } |
There was a problem hiding this comment.
The NewPasswordSameAsOld check is performed twice: once inline inside the and! newPwd block (line 2151-2154, using refuteWith) and once in the and! _ = cross-field block (line 2169, using disputeWithFact). This redundancy is confusing in a documentation example meant to illustrate best practices. The inline check also occurs as a refuteWith which stops validation of newPwd further, while the duplicate check in the cross-field block uses disputeWithFact. Consider removing the inline check and relying solely on the cross-field validation to demonstrate a clean separation of concerns.
|
|
||
| let validateUsername (un: string option) : Proof<UsernameFailures, Username> = | ||
| validation { | ||
| withField (fun () -> un) |
There was a problem hiding this comment.
withField requires a member access expression — such as fun () -> vm.FieldName — to extract the field name via reflection (selector.Body :?> MemberExpression). Using withField (fun () -> un) where un is a function parameter, not a property of a record/object, would throw an InvalidCastException at runtime because the expression body is not a MemberExpression. The correct usage here should be withValue un since un is already the extracted value.
| withField (fun () -> un) | |
| withValue un |
| let! _ = validation { | ||
| withValue (password, confirmPassword) | ||
| disputeWithFact PasswordMismatch (fun (p, cp) -> Password.unwrap p = cp) | ||
| qed | ||
| } |
There was a problem hiding this comment.
The cross-field password-match validation uses let! _ = (sequential binding) rather than and! _ = (parallel binding). This means if any of the preceding and! fields were refuted (e.g., missing required password), the cross-field check would be skipped entirely. This diverges from the established pattern in samples/GettingStarted/Types.fs:252 and samples/FSharp.Data.Validation.Samples/ComplexTypes.fs:64 which use and! _ = validation { withValue vm; disputeWithFact ...; qed }. Additionally, the PasswordMismatch failure is also used as the "required" failure for confirmPassword at line 1763 (refuteWith (isRequired PasswordMismatch)), which is semantically confusing — a missing field and a mismatched password produce the same failure value, preventing consumers from distinguishing the two cases.
| validation { | ||
| withValue item | ||
| refuteWithProof (validateOrderItem >> Proof.mapInvalid ItemError) | ||
| qed |
There was a problem hiding this comment.
The validateEach element function incorrectly calls qed at the end. The qed operation strips the ValueCtx wrapper, making the lambda return VCtx<OrderFailure, OrderItem>, but validateEach requires the function to return VCtx<'F, ValueCtx<'B>>. This example would not compile due to a type mismatch. The fix is to remove the qed call — after refuteWithProof, the context is already VCtx<OrderFailure, ValueCtx<OrderItem>> which is the type validateEach expects.
| qed |
| This function accepts a function with a signature of `'A -> VCtx<'F, ValueCtx<'B>>` that validates each element. | ||
| The function should be contained in the `validation` computation expression, which returns the appropriate `VCtx` type wrapping a `ValueCtx`. | ||
| The result accumulates all validation failures across elements while preserving valid transformed values. |
There was a problem hiding this comment.
The description was updated to correctly state that the element function must return VCtx<'F, ValueCtx<'B>>, but the code example below (line 1539) still shows validateEach (fun a -> validation { withValue a; ...; qed; }). Calling qed strips the ValueCtx wrapper, which contradicts the updated description and would produce a type error. The qed call in the existing example should be removed to be consistent with the updated description.
| let validateItem idx (item: ItemVM) : VCtx<ItemFailure, ValueCtx<OrderItem>> = | ||
| validation { | ||
| let! name = | ||
| validation { | ||
| withField (fun () -> item.Name) | ||
| refuteWith (isRequired NameRequired) | ||
| qed id | ||
| } | ||
| and! price = | ||
| validation { | ||
| withField (fun () -> item.Price) | ||
| disputeWithFact PriceTooLow (fun p -> p >= 0.01M) | ||
| qed id | ||
| } | ||
| return { Name = name; Price = price } | ||
| } | ||
|
|
||
| withValue this.Items | ||
| validateEach validateItem | ||
| qed id |
There was a problem hiding this comment.
The validateItem function is annotated as returning VCtx<ItemFailure, ValueCtx<OrderItem>>, but the function body uses let!/and! bindings with qed id and ends with return { Name = name; Price = price }. The return in the CE calls this.Return(a) which produces ValidCtx { Name = name; Price = price }, which is VCtx<ItemFailure, OrderItem> — NOT VCtx<ItemFailure, ValueCtx<OrderItem>>. This is a type annotation mismatch that would cause a compile error. The validateEach operation requires the element function to return VCtx<'F, ValueCtx<'B>>, but a function body using return or qed cannot produce that. This example needs to be reworked to correctly demonstrate validateEach's expected function signature.
| validation { | ||
| withField (fun () -> un) | ||
| refuteWith (isRequired Empty) | ||
| refuteWith (fun u -> if String.length u > 50 then Some TooLong else None) |
There was a problem hiding this comment.
refuteWith expects a function with signature 'A -> Result<'B, 'F>, but the lambda fun u -> if String.length u > 50 then Some TooLong else None returns 'F option (specifically UsernameFailures option). This would be a compile error. The correct usage should return Error TooLong on failure and Ok u on success.
| refuteWith (fun u -> if String.length u > 50 then Some TooLong else None) | |
| refuteWith (fun u -> if String.length u > 50 then Error TooLong else Ok u) |
Overview
This PR adds comprehensive general documentation improvements to the README, including best practices, common patterns, and advanced validation scenarios.
Changes
New Sections Added
Quick Start - A minimal, practical example showing how to validate an email address with key concepts highlighted
FAQ / Common Patterns (~600 lines) - Five Q&A sections answering common questions:
refutevsdispute?mapInvalid?Advanced Patterns (~500 lines) - Seven detailed pattern examples:
flattenProofsfor nested validationsmapInvalid