The following code is an example of railway-oriented programming that allows a password string to be validated:
let (|SpecialChar|_|) (str : string) =
let chars = [| '!'; '@'; '#' |]
str.IndexOfAny(chars) >= 0
let (|AlphaNumeric|_|) (str : string) =
Array.tryFind System.Char.IsLetterOrDigit (str.ToCharArray())
let lengthBeAtLeast8 (passwords : string) =
match passwords.Length with
| length when length < 8 -> Error "Passwords must be at least 8 characters"
| _ -> Ok passwords
let containsAlphaNumeric (password : string) =
match password with
| AlphaNumeric _ -> Ok password
| _ -> Error "Password must contain alphanumeric characters"
let containsSpecialCharacters (password : string) =
match password with
| SpecialChar -> Ok password
| _ -> Error "Password must contain special characters"
let notBePassword (password : string) =
match password with
| "P@ssword" -> Error "Choose something else"
| _ -> Ok password
// string -> Result<string,string>
let validatePasswordRailway =
lengthBeAtLeast8
>> Result.bind containsAlphaNumeric
>> Result.bind containsSpecialCharacters
>> Result.bind notBePassword
I could also use lazy seq to achieve the same:
let lengthBeAtLeast8Opt (passwords : string) =
match passwords.Length with
| length when length < 8 -> Some "Passwords must be at least 8 characters"
| _ -> None
let containsAlphaNumericOpt (password : string) =
match password with
| AlphaNumeric _ -> None
| _ -> Some "Password must contain alphanumeric characters"
let containsSpecialCharactersOpt (password : string) =
match password with
| SpecialChar -> None
| _ -> Some "Password must contain special characters"
let notBePasswordOpt (password : string) =
match password with
| "P@ssword" -> Some "Choose something else"
| _ -> None
let passwordValidations = seq {
lengthBeAtLeast8Opt
containsAlphaNumericOpt
containsSpecialCharactersOpt
notBePasswordOpt
}
// string -> string option
let validatePasswordOpt password =
passwordValidations
|> Seq.tryPick (fun f -> f password)
Which style is recommended? Am I correct that the seq version can potentially run faster?
-
\$\begingroup\$ Are both code blocks written or maintained by you? \$\endgroup\$Sᴀᴍ Onᴇᴌᴀ– Sᴀᴍ Onᴇᴌᴀ ♦2025年01月12日 19:02:43 +00:00Commented Jan 12 at 19:02
-
1\$\begingroup\$ New to F#, this I wrote just as an example. \$\endgroup\$Parsa99– Parsa992025年01月12日 19:05:09 +00:00Commented Jan 12 at 19:05
2 Answers 2
Software engineers strive to satisfy a business need by writing small understandable functions that compose nicely. Using DbC with exceptions is one way to accomplish that, and railway-oriented programming is another way. We might, for example,
- receive a POST request
- do a DB query
- INSERT a new row containing queried data
- log that a new row was inserted
- send a confirmation email
- log that the email was sent
If a step fails, it doesn't make sense to continue with the rest, so we should bail out early with a helpful diagnostic.
Scott Wlaschin mentions several reasons to avoid such a style, which I won't repeat here, and also a few situations where it can be a good fit:
- Domain Errors
- Panics
- Infrastructure Errors
business problem
@Gebb correctly points out that this particular password use case maybe isn't the best fit for trying out the technique, as a UI should gather up all issues rather than bail out early. This reduces user frustration so all issues may be fixed before the next click on the submit button.
web-safe URLs
Having just three special chars seems to account for an unusually
small amount of entropy being injected into the shared secret.
It is especially surprising that _
underscore and -
dash
are left out.
If there is some other goal at work here,
like wanting only characters that may safely appear
in URLs, then be sure to write a comment to that effect.
Future maintainers will thank you.
The OP code will happily accept e.g. unicode emojis,
which I assume is intended.
consistent guards
nit: Some of the matches handle the Ok
happy path first,
and alas some instead report Error
first.
To the extent that we can easily invert a condition,
such as length >= 8
, consider preferring a consistent order,
for the convenience of maintainers reading this code.
which style
Which style is recommended?
It's a matter of opinion and personal preference.
Mine is for the seq
version since
it omits the red herring of returning password
as though that value had been carefully computed.
After all, we just have a glorified boolean here.
Neither style is appreciably faster,
since they both short-circuit.
Both avoid evaluating later checks
as soon as an earlier check fails.
Passing around a Some
string works fine,
but I feel it would be more self documenting
to create an app-specific Error
thin wrapper
for strings, to clarify that it is pejorative,
it is a Bad Thing being returned.
Of course, this starts to get at Wlaschin's
advice about "do not reinvent exceptions!".
The diagnostic message wrapper would be much
less powerful than true exceptions, since they
alter control flow and give an informative stack trace.
nit: Maybe introducing a separate passwordValidations
is a bit on the verbose side?
The expression in validatePasswordRailway
seems succinct and expressive.
Neither, I would say. It's more user friendly to accumulate errors and show them all to the user (or the client calling your API). Your implementation returns the first error detected, making the user potentially discover the next violation in the next call.
Consider implementing validation via an applicative functor, as described here.
An excerpt from that article:
let validateDateOfBirth input = // string -> Result<DateTime,
ValidationFailure list>
match input with
| IsValidDate dob -> Ok dob //Add logic for DOB
| _ -> Error [ DateOfBirthIsInvalidFailure ]
let apply fResult xResult = // Result<('a -> 'b),
'c list> -> Result<'a,'c list> -> Result<'b,'c list>
match fResult,xResult with
| Ok f, Ok x -> Ok (f x)
| Error ex, Ok _ -> Error ex
| Ok _, Error ex -> Error ex
| Error ex1, Error ex2 -> Error (List.concat [ex1; ex2])
let (<!>) = Result.map
let (<*>) = apply
let create name email dateOfBirth =
{ Name = name; Email = email; DateOfBirth = dateOfBirth }
let validate (input:UnvalidatedUser) : Result<ValidatedUser,
ValidationFailure list> =
let validatedName = input.Name |> validateName
let validatedEmail = input.Email |> validateEmail
let validatedDateOfBirth = input.DateOfBirth |>
validateDateOfBirth
create <!> validatedName <*> validatedEmail
<*> validatedDateOfBirth
let notValidTest =
let actual = validate' { Name = ""; Email = "hello";
DateOfBirth = "" }
let expected = Error [ NameIsInvalidFailure;
EmailIsInvalidFailure; DateOfBirthIsInvalidFailure ]
expected = actual