C
C#2y ago
Meigs2

Composing a validation function using functional concepts

Hey yall, I've been trying to get into using and applying functional concepts in some of my projects and I'm having some conceptual issues with composing monads. Problem: I'm creating a structure to represent a "Version" for a project of mine for structured versioning of databases. A Version is comprised of a Major, Minor, and Patch version numbers, represented by integers. All Versions require a Major number, but do not require a Minor or Patch version. If a Patch version is present, a minor version is required. i.e.
Valid: (0, 0, 0)
Valid: (1, 45, 0)
Valid: (5, 3)

INVALID: (1, {}, 3)
INVALID: ()
INVALID: ({}, 3)
Valid: (0, 0, 0)
Valid: (1, 45, 0)
Valid: (5, 3)

INVALID: (1, {}, 3)
INVALID: ()
INVALID: ({}, 3)
What I need help with I could write the validation function very easily in regular old C#, however I think this problem can better be solved in a more "functional" manner, but I believe I'm missing a fundamental concept in functional programming. When implementing Validate, I don't quite understand how I can functionally compose a chain of validation results when the rules for the validation logic comprise of smaller functions whos validation inputs and outputs use more specific values than the containing structure. i.e.
Validate:
Version version -> Validation<Version>

Rule 1:
int Major -> Validation<int>

Rule 2:
int Minor -> Validation<int>
... etc
Validate:
Version version -> Validation<Version>

Rule 1:
int Major -> Validation<int>

Rule 2:
int Minor -> Validation<int>
... etc
I dont understand how to compose Validate when the individual rules use ints. I can call Rule1(version.Major), which returns a Validation<int>, but that means when calling Bind on the result, the value I get into the next function is an int, not the original Version that I need to continue with. The library I'm using is half home-rolled, using LaYumba.Functional as a base. I'm not looking for an exact solution here, just the right functional concept for "passing down" the original value in a series of Either-like monads. Details: My record is defined as such:
public record Version
{
public int Major { get; init; }
public Option<int> Minor { get; init; }
public Option<int> Patch { get; init; }

public override string ToString() => $"{Major}{"." + Minor}.{"." + Patch}";

private Version(int major, Option<int> minor, Option<int> patch)
{
Major = major;
Minor = minor;
Patch = patch;
}
}
public record Version
{
public int Major { get; init; }
public Option<int> Minor { get; init; }
public Option<int> Patch { get; init; }

public override string ToString() => $"{Major}{"." + Minor}.{"." + Patch}";

private Version(int major, Option<int> minor, Option<int> patch)
{
Major = major;
Minor = minor;
Patch = patch;
}
}
Where Option is a monad with the usual Bind, Map, etc defined. I have a validation function, and some other validation rules defined as follows:
public static Validation<Version> Validate(Version version)
{
// TODO: implement
}

private static Validation<int> IsValidMajorVersion(int major) =>
major < 0 ? Invalid("Major version must be greater than or equal to 0") : Valid(major);

private static Validation<int> IsValidMinorVersion(int minor) =>
minor < 0 ? Invalid("Minor version must be greater than or equal to 0") : Valid(minor);

private static Validation<int> IsValidPatchVersion(int patch) =>
patch < 0 ? Invalid("Patch version must be greater than or equal to 0") : Valid(patch);
public static Validation<Version> Validate(Version version)
{
// TODO: implement
}

private static Validation<int> IsValidMajorVersion(int major) =>
major < 0 ? Invalid("Major version must be greater than or equal to 0") : Valid(major);

private static Validation<int> IsValidMinorVersion(int minor) =>
minor < 0 ? Invalid("Minor version must be greater than or equal to 0") : Valid(minor);

private static Validation<int> IsValidPatchVersion(int patch) =>
patch < 0 ? Invalid("Patch version must be greater than or equal to 0") : Valid(patch);
Where Validation<T> is a success/fail style Either monad where Left is an enumerable of errors, and Right is the value of the successful validation. I could make all the "lower" validation rules take in a version and my issues would be solved but... that seems not quite correct, and I feel like there's a functional solution to compose them properly. I have to somehow lift version into a monad and do a Match on success or fail? I can lift the version into an Option and match on Some/None, but I still have to pass the version around? I think I'm missing something critical conceptually. Thanks!
1 Reply
Meigs2
Meigs22y ago
I hope that makes some sense So far I have:
public static Validation<Version> Validate(Version version)
{
return version.ToOption().Match(
Some: v => IsValidMajorVersion(v.Major).Match(
Valid: _ => IsValidMinorVersion(v.Minor).Match(
Valid: _ => IsValidPatchVersion(v.Minor).Match(
Valid: _ => Valid(v),
Invalid: e => Invalid(e)),
Invalid: e => Invalid(e)),
Invalid: e => Invalid(e)),
None: () => Invalid("Version is not valid"));
}

private static Validation<int> IsValidMajorVersion(int major) =>
major.ToSome().Map(x => x < 0 ? Invalid("Major version must be greater than or equal to 0") : Valid(x)).Value;

private static Validation<Option<int>> IsValidMinorVersion(Option<int> minor) =>
minor.Match(
None: () => Valid(Option<int>.None),
Some: x => x < 0 ? Invalid("Minor version must be greater than or equal to 0") : Valid(Option.Some(x))
);

private static Validation<Option<int>> IsValidPatchVersion(Option<int> patch) =>
patch.Match(
None: () => Valid(Option<int>.None),
Some: x => x < 0 ? Invalid("Patch version must be greater than or equal to 0") : Valid(Option.Some(x))
);
public static Validation<Version> Validate(Version version)
{
return version.ToOption().Match(
Some: v => IsValidMajorVersion(v.Major).Match(
Valid: _ => IsValidMinorVersion(v.Minor).Match(
Valid: _ => IsValidPatchVersion(v.Minor).Match(
Valid: _ => Valid(v),
Invalid: e => Invalid(e)),
Invalid: e => Invalid(e)),
Invalid: e => Invalid(e)),
None: () => Invalid("Version is not valid"));
}

private static Validation<int> IsValidMajorVersion(int major) =>
major.ToSome().Map(x => x < 0 ? Invalid("Major version must be greater than or equal to 0") : Valid(x)).Value;

private static Validation<Option<int>> IsValidMinorVersion(Option<int> minor) =>
minor.Match(
None: () => Valid(Option<int>.None),
Some: x => x < 0 ? Invalid("Minor version must be greater than or equal to 0") : Valid(Option.Some(x))
);

private static Validation<Option<int>> IsValidPatchVersion(Option<int> patch) =>
patch.Match(
None: () => Valid(Option<int>.None),
Some: x => x < 0 ? Invalid("Patch version must be greater than or equal to 0") : Valid(Option.Some(x))
);
but I feel like the composition is not great in the Validate function, with all the nesting. oh, I think I got it! I shouldnt be validating on the individual items, I should be composing around the Version.
public static Validation<Version> Validate(Version version)
{
return version.ToOption()
.Match(
Some: v =>
IsValidMajorVersion(v)
.Bind(IsValidMinorVersion)
.Bind(IsValidPatchVersion),
None: () => Invalid("Version cannot be null"));
}

private static Validation<Version> IsValidMajorVersion(Version version) => version.Major >= 0
? Valid(version)
: Invalid<Version>($"Major version must be greater than or equal to 0, but was {version.Major}");

private static Validation<Version> IsValidMinorVersion(Version minor) => minor.Minor.Match(
Some: m => m >= 0
? Valid(minor)
: Invalid<Version>($"Minor version must be greater than or equal to 0, but was {m}"),
None: () => Valid(minor));

private static Validation<Version> IsValidPatchVersion(Version patch) =>
patch.Patch.Match(
Some: p => p >= 0
? Valid(patch)
: Invalid<Version>($"Patch version must be greater than or equal to 0, but was {p}"),
None: () => Valid(patch));
public static Validation<Version> Validate(Version version)
{
return version.ToOption()
.Match(
Some: v =>
IsValidMajorVersion(v)
.Bind(IsValidMinorVersion)
.Bind(IsValidPatchVersion),
None: () => Invalid("Version cannot be null"));
}

private static Validation<Version> IsValidMajorVersion(Version version) => version.Major >= 0
? Valid(version)
: Invalid<Version>($"Major version must be greater than or equal to 0, but was {version.Major}");

private static Validation<Version> IsValidMinorVersion(Version minor) => minor.Minor.Match(
Some: m => m >= 0
? Valid(minor)
: Invalid<Version>($"Minor version must be greater than or equal to 0, but was {m}"),
None: () => Valid(minor));

private static Validation<Version> IsValidPatchVersion(Version patch) =>
patch.Patch.Match(
Some: p => p >= 0
? Valid(patch)
: Invalid<Version>($"Patch version must be greater than or equal to 0, but was {p}"),
None: () => Valid(patch));