This post will continue our discussion of the safe coding practices in C#. I encourage you to read the post on Maybe types before proceeding with this one. In the previous post we started our journey to build a more safer version of the configuration API than what is provided out of the box with dotnet.
Here is where we left off. We want to parse the below json config
{
"trolleyApi": {
"url": "http://api.trolley.local",
"timeOutInMilliSeconds": 1000
}
}
into a instance of the C# below type.
public class ApiConfig
{
public required string Url {get; init;}
public required int TimeOutInMilliSeconds {get; init;}
}
This is the implementation we came with which leverages the Maybe type. Refer to the previous post for the implementation details.
Maybe<ApiConfig> apiConfig =
from url in configuration.GetString("apiConfig:url")
from timeout in configuration.GetInt("apiConfig:timeOutInMilliSeconds")
select new ApiConfig(url, timeout);
The catch with the above is that you either get a valid ApiConfig instance or a Maybe.None instance if any of the configs are missing or is invalid. Wouldn't it be nice if our logs would show that we failed to parse the configuration and what went wrong?
{
"Type": "ConfigurationParseError",
"TrolleyConfig": {
"Errors": {
"TimeOutInMilliSeconds": { "Type": "Required" },
"Url": {
"Type": "ValueFormatError",
"ProvidedValue": "invalid-url-string"
}
}
}
}
Lets look to see how we can achieve the above.
Introducing the Result type
Instead of throwing an exception, a Result type can be used to represent failure scenario in our code.
Exceptions should be used for truly exceptional scenarios.
StackOverflow is an exceptional scenario. Missing or invalid config value is an expected failure case and should be handled accordingly. A later section in this post will go into some considerations of using the Result type over Exception, for now, lets look to see how to put the Result type into action.
We will use the Result type implementation from CSharpFunctionalExtensions before looking to implement our own.
dotnet add package CSharpFunctionalExtensions
A Result like a Maybe type has two possible states:
A success scenario, containing the results of a performed operation
A failure scenario, containing the failure information.
To create an instance of the Result type, use the factory methods on the Result type.
// create a result instance in the success state.
Result<int> computedResult = Result.Success(42);
// create a result instance in the failed state.
Result<int> failedResult =
Result.Failure<int>("Failed to compute the value");
It provides the same setup of helper methods as the Maybe type.
- Accessing the Value (success state) of a result type.
if(computedResult.IsSuccess){
Console.WriteLine(computedResult.Value);
}
- Accessing the Error (failure state) is done in a similar manner
if(failedResult.IsFailure){
Console.WriteLine(failedResult.Error);
}
Attempting to access the Value or Error property without prior verification of the IsSuccess or IsFailure properties may lead to an exception. Similar to the Maybe type, Result is another container type.
Once a value is encapsulated within a Result type, it is advisable to employ methods such as Select and SelectMany when working with a Result instance. 3. Employ the Select or Map method when applying a function to a result instance.
Result<int> doubledResult = computedResult.Map(x => x * 2);
// Result.Success(84)
Here, the lambda expression x => x*2
is executed only when the computedResult instance is in the success state. Map method utilizes the IsSuccess property, and its implementation is as follows.
Result<K> Map<J,K>(this Result<J> result, Func<J, K> mapFunction)
=> result.IsSuccess
? Result.Success(mapFunction(result.Value))
: Result.Failure(result.Error);
- Utilize the SelectMany or Bind method when you want to apply a function that returns another Result instance.
Result<int> ParseInt(string value) =>
int.TryParse(value, out var intValue)
? Result.Success(intValue)
: Result.Failure<int>("Failed to parse the value");
Result<string> stringInt = Result.Success("42");
Result<int> intResult = stringInt.SelectMany(s => ParseInt(s));
The same can be expressed in the from syntax format as follows:
Result<int> result =
from stringInt in Result.Success("42")
from intResult in ParseInt(stringInt)
select intResult;
SelectMany, similar to Map, is a function that takes a Result type and applies it to a function that returns another Result type.
Result<K> SelectMany<J,K>(this Result<J> result, Func<J, Result<K>> mapFunction)
=> result.IsSuccess
? mapFunction(result.Value)
: Result.Failure(result.Error);
Similar to the Maybe type, use Select and SelectMany to combine functions that operate on the Result type. A Result type's value should be retrieved primarily at system boundaries, particularly when it is required for persistence or inclusion in an API response.
We will see more examples of combining Result types in this post.
When to use Maybe vs the Result type
A common question when introducing the Result type is how it differs from the Maybe type. Although both are container types (or Monads, I know, that scary "M" word) with similar methods like Select and SelectMany, they are employed in distinct contexts. The Maybe type is used to signify the absence of a value, focusing solely on that aspect. On the other hand, the Result type represents errors, or more precisely, predictable errors that have been anticipated. It's critical to acknowledge that there will always be unpredictable errors or bugs that may manifest as exceptions. This topic will be explored in more detail in a subsequent post.
As a side note, I encourage you to explore this blog post by Mark Seemann . It provides a valuable mental model for contemplating errors and largely reflects how I approach error modelling using the Result type.
The absence of a value by itself does not necessarily imply an error, and that's precisely why we have two distinct types to represent these concepts. For example, consider a function designed to return a product based on its identifier.
public record Product(int Id, string Name, decimal Price);
Maybe<Product> GetProductById(int id) =>
id switch
{
1 => Maybe<Product>.From(new Product(1, "Product 1", 10)),
2 => Maybe<Product>.From(new Product(2, "Product 2", 20)),
_ => Maybe<Product>.None
};
This maybe invoked from an API controller action method:
[Route("/{version}/products/{sku}")]
public IHttpActionResult GetProductBySku(int sku)
{
var productMaybe = GetProductById(sku);
return productMaybe.HasValue
? Ok(productMaybe.Value)
: NotFound();
}
Now, the same method could be applied in the context of adding a product to a basket. In this specific scenario, we cannot proceed to add an item to the basket if the product is missing.
public record Trolley(int Id, IEnumerable<int> ProductIds)
Result<Trolley> AddProductToTrolley(Trolley trolley, int productId)
{
var productMaybe = GetProductById(productId);
if(productMaybe.HasValue)
{
var product = productMaybe.Value;
var productIds = trolley.ProductIds.Append(product.Id);
return Result.Success(new Trolley(trolley.Id, productIds));
}
else
{
return Result.Failure<Trolley>($"Product with id {productId} not found");
}
}
In this situation, we can convert the Maybe<Product>
into a Result<Product>
by invoking the ToResult extension method.
Result<Trolley> AddProductToTrolley(Trolley trolley, int productId) =>
GetProductById(productId)
.ToResult("Product not found")
.Map(
product => new Trolley(
trolley.Id, trolley.ProductIds.Append(product.Id)
)
);
It's a common practice to model methods that retrieve an entity from the database (an API call, or another external store) as returning a Maybe<Entity>
. Subsequently, it can be converted into a Result instance based on the specific context in which it is consumed.
Combining Maybe and Result types
Result and Maybe are not orthogonal types; in fact, they are often used in conjunction. Let's revisit the method of fetching a product by its identifier. In reality, this process involves a lookup to some external store and can be modelled as follows.
async Task<Result<Maybe<Product>>> GetProductByIdAsync(int id)
{
try
{
// use your favourite ORM of choice
// to fetch the product from DB
var product = Maybe<Product>.From(
new Product(id, $"Product {id}", 10)
);
var result = Result.Success(product);
return await Task.FromResult(product);
}
catch (Exception)
{
// this is a contrived example
return
Result.Failure<Maybe<Product>>("Failed to fetch product");
// we will look to see how to capture the exception
//and return a failure result
}
}
Here we wrap the Maybe type with the Result type. The Result type is used to capture any IO errors when attempting to connect with the external data store. In the next section, we will see how to capture the exception into a strongly defined error type.
Strongly typed errors with Result<T,E>
The real power with Result types comes with the ability to define our own Error types. The Result<T>
usage we have seen above is a special case of Result<T,E>
where the error type (E) is fixed to a string type. Along with semantic logging, incorporating a strongly typed error object can establish a log source with extensive query capabilities, moving away from the traditional approach of relying on string-based searches.
Lets revisit our configuration example from before.
{
"Type": "ConfigurationParseError",
"TrolleyConfig": {
"Errors": {
"TimeOutInMilliSeconds": { "Type": "Required" },
"Url": {
"Type": "ValueFormatError",
"ProvidedValue": "invalid-url-string"
}
}
}
}
We will create some types to represent the error from parsing the configuration.
public interface IValidationError { }
public record RequiredError() : IValidationError
{
public string Type => "Required";
}
public record ValueFormatError(string ProvidedValue) : IValidationError
{
public string Type => "ValueFormatError";
}
public record ObjectValidationError(
IDictionary<string, IValidationError> Errors) : IValidationError
{
}
public record ConfigurationParseError(IValidationError ValidationError)
{
public string Type => "ConfigurationParseError";
}
Using the above, we can construct an error object showing a failed attempt to parse an ApiConfig type.
var errorObject = new ConfigurationParseError(
new ObjectValidationError(
new Dictionary<string, IValidationError>()
{
["timeOutInMilliSeconds"] = new RequiredError(),
["url"] = new ValueFormatError("trolley.local")
}
)
);
var apiConfigResult =
Result.Failure<ApiConfig, ConfigurationParseError>(configurationError);
Creating these error instances manually can be tedious and error-prone. That's precisely why we're committed to developing a fluent validation API, aiming to enhance the overall developer experience. To stay focused on our ongoing discussion about Result types, we'll delve deeper into this topic in the upcoming post of our series. However, for a sneak peek, here's a preview of what the final version will entail.
public static Maybe<string> For(
this IConfiguration configuration, string key)
{
string? value = configuration[key];
return value == null
? Maybe<string>.None
: Maybe<string>.From(value);
}
public Result<ApiConfig, IValidationError> GetApiConfig(
IConfiguration config, string configKey) =>
Validate<ApiConfig>
.For(u => c.Url, config.For($"{configKey}:url").ToUrl())
.For(t => c.TimeoutInMilliSeconds,
config.For($"{configKey}:timeOutInMilliSeconds").ToInt())
.Create((url,timeout) => new ApiConfig(url, timeout))
This approach of constructing config objects is both composable and scalable, making it well-suited for building more extensive object hierarchies.
{
"trolley": {
"apiConfig": {
"url": "http://api.trolley.local",
"timeoutInMilliSeconds": 2000
},
"serviceBusConfig": {
"name": "trolley.servicebus.windows.net",
"topic": "abandoned.carts"
}
}
}
public record ApiConfig(Url Url, int TimeoutInMilliSeconds)
public record ServiceBusConfig(string Name, string Topic)
public record TrolleyConfig(
ApiConfig ApiConfig, ServiceBusConfig BrokerConfig)
public Result<ApiConfig, IValidationError> GetApiConfig(
IConfiguration config, string configKey) =>
Validate<ApiConfig>
.For(u => c.Url, config.For($"{configKey}:url").ToUrl())
.For(t => c.TimeoutInMilliSeconds,
config.For($"{configKey}:timeOutInMilliSeconds").ToInt())
.Create((url,timeout) => new ApiConfig(url, timeout));
public Result<ServiceBusConfig, IValidationError> GetServiceBusConfig(
IConfiguration config, string configKey) =>
Validate<ApiConfig>
.For(u => c.Name, config.For($"{configKey}:name").Required())
.For(t => c.Topic, config.For($"{configKey}:topic").Required())
.Create((name, topic) => new ServiceBusConfig(name, topic));
public Result<TrolleyConfig, IValidationError> GetTrolleyConfig(
IConfiguration config) =>
Validate<TrolleyConfig>
.ForObj(c => c.ApiConfig, GetApiConfig(config, "apiConfig"))
.ForObj(c => c.BrokerConfig,
GetServiceBusConfig(config, "serviceBusConfig"))
.Create((apiConfig, serviceBusConfig) =>
new TrolleyConfig(apiConfig, serviceBusConfig));
Querying logs
With the above approach, we accomplish several things:
- The most crucial of which is establishing a function signature that signals the potential for a failure when parsing config values. Consider the function signature
public Result<TrolleyConfig, IValidationError> GetTrolleyConfig(
IConfiguration config) { ... }
versus the more traditional one
public TrolleyConfig GetTrolleyConfig(IConfiguration config) { ... }
The signature of the first function suggests that Trolley configuration might not be obtained if the configuration file lacks the required values. The second signature, on the other hand, incorrectly suggests that when provided with a configuration instance, it will return a TrolleyConfig without exception. By using the Result type we get to create function signatures that do not lie.
It advocates for an error-first programming mindset, fostering more predictable and reliable software. This approach compels the function consumer to address the failure case explicitly, preventing potential oversights that could lead to unhandled exceptions during runtime. While the distinction between both options might seem inconsequential in this context, it holds substantial importance when the entire application is constructed in this fashion. We will explore examples of this in the subsequent sections.
Last but certainly not least, when encountering an invalid configuration, we generate a meticulously crafted error object that consolidates all validation errors into a single log entry, rather than failing at the first instance of an error.
Result<TrolleyConfig, IValidationError> trolleyConfig =
GetTrolleyConfig(configurationSource);
if(trolleyConfig.IsFailure)
_logger.LogError("{Type}, {TrolleyConfig}",
"ConfigurationParseError", trolleyConfig.Error);
{
"Type": "ConfigurationParseError",
"TrolleyConfig": {
"Errors":{
"ApiConfig": {
"Errors": {
"TimeOutInMilliSeconds": { "Type": "Required"},
"Url": {
"Type": "ValueFormatError",
"ProvidedValue": "trolley.local"
}
}
},
"BrokerConfig": {
"Errors": {
"Topic": { "Type": "Required"},
}
},
}
}
}
This is an example employing DQL (from Dynatrace) to retrieve all configuration errors. Please refer to your logging provider's documentation for information on their specific query language.
fetch logs
| filter Type == "ConfigurationParseError"
I hope you can begin to appreciate the benefits of adopting such a system for generating consistent, easily queryable log entries, eliminating the need to rely on string searches within what would otherwise be stack traces from exceptions.
Composing functions returning Result types
We've already explored examples of combining different Result types in the validation example provided earlier. Now, I'd like to present another example from a business application perspective to reinforce these concepts.
Consider the use case of adding a Product to a Trolley. This process can be broken down into the following steps:
- Get the existing trolley for a shopper - or create a new one if none exist.
public async Task<Result<Trolley, SqlError> GetTrolleyForShopper(
int shopperId)
{
Result<Maybe<Trolley>, SqlError> existingTrolley =
await // fetch from DB using the ORM tool of your choice
return existingTrolley.Select(trolleyMaybe =>
trolleyMaybe.HasValue
// return the trolley if one exist
? trolleyMaybe.Value
// or create a new one
: new Trolley()
);
}
SqlError is an error type that parses and captures any error that occurs while accessing a SQL database. We will discuss implementation details in the next post.
- Get the product to be added into the trolley.
public Task<Result<Maybe<Product>,SqlError> GetProductBySku (string sku)
{
Result<Maybe<Product>, SqlError> productMaybe =
await // fetch from DB using the ORM tool of your choice
return productMaybe ;
}
The product we intend to add may be missing or no longer exist, which is why we are returning a
Maybe<Product>
instead of a Product.Attempting to add an empty product to the cart should be treated as an expected error condition.
However, it is not the responsibility of this function to handle such cases, and we will explore how this is managed in a higher-level composing function.
It's important to note that the absence of a product may not always constitute an error condition in all scenarios and should be handled as needed on a case-by-case basis.
- Add the product to trolley
- The trolley type contains an instance method to add a new product to a list of existing items in the trolley.
public record TrolleyItem(Product Product, int Quantity)
public record Trolley(IEnumerable<TrolleyItem> TrolleyItems)
{
public Trolley AddItem(TrolleyItem item) =>
TrolleyItems.Concat(new [] {item});
}
- Tracking analytics
Assume we also need to track cart events to an analytics store used to measure conversions.
We could have the function below that tracks a cart event every time an item is added to the basket.
public async Task<Result<CartEventResponse,HttpError>>
TrackEventInAnalyticStore(CartEvent cartEvent)
{
var eventResponse = await _httpClient.Post(
"/track-event", cartEvent);
Result<CartEventResponse, HttpError> response =
ParseResponse(eventResponse);
return response;
}
As with SQLErrors, HTTPErrors represent errors during an HTTP operation.
Errors will also contain auxiliary information like URLs, response codes, and anything else we deem necessary to troubleshoot.
As before, we will discuss implementation details in a subsequent post.
- Persisting the trolley Lastly, persist the changes to trolley and return the response to the user.
public async Task<Result<Trolley, SqlError>> PersistTrolley(
Trolley trolley)
{
Result<Trolley, SqlError> persistedTrolley =
await // persist trolley using the ORM tool of choice
return persistedTrolley;
}
Now that we have all the building blocks, let's see how they can be combined.
There is one challenge we will face because each method has a different error type - some return a SQLError, whereas others return an HttpError. SelectMany requires that all Result types have the same error type before they can be combined.
This would fail with a compile time error on line 7, where we try to handle the analytics store response.
var result =
from trolley in GetTrolleyForShopper(4311) // return a Result<Trolley,SqlError>
from product in GetProductBySku("2147").ToResult() // Result<Product, SqlError>
// ToResult() converts a Result<Maybe<Product>, SqlError> to Result<Product, SqlError>
let updatedTrolley = trolley.AddItem(new TrolleyItem(product, 5))
from persistedTrolley in PersistTrolley(updatedTrolley)
from trackEvent in TrackEventInAnalyticsStore(new AddToTrolley(product,5)) // Result<CartEventResponse, HttpError>
// the expression fails above on step 7, as we get an HttpError instead of a SqlError
select persistedTrolley;
The way around it would be to map all the errors into a common type and is in-fact beneficial to do so as we will see shortly.
public enum TrolleyErrorType
{
FailedToFetchTrolley,
FailedToFetchProduct,
RequestedProductMissing,
FailedToTrackCartEvent,
FailedToPersistTrolley
}
public record TrolleyError
{
private TrolleyError(TrolleyErrorType errorType,
Maybe<IError> error, Maybe<string> missingProductSku)
{
ErrorType = errorType;
Error = error;
MissingProductSku = missingProductSku;
}
public TrolleyErrorType ErrorType { get; }
public Maybe<IError> Error { get; }
public Maybe<string> MissingProductSku { get; }
public static TrolleyError FailedToFetchTrolley(SqlError error) =>
new TrolleyError(TrolleyErrorType.FailedToFetchTrolley,
error, Maybe<string>.None)
public static TrolleyError FailedToFetchProduct(SqlError error) =>
new TrolleyError(TrolleyErrorType.FailedToFetchProduct,
error, Maybe<string>.None)
public static TrolleyError RequestProductMissing(string sku) =>
new TrolleyError(TrolleyErrorType.RequestedProductMissing,
Maybe<Error>.None, sku)
public static TrolleyError FailedToTrackCartEvent(HttpError error)=>
new TrolleyError(TrolleyErrorType.FailedToTrackCartEvent,
error, Maybe<string>.None)
public static TrolleyError FailedToPersistTrolley(SqlError error) =>
new TrolleyError(TrolleyErrorType.FailedToPersistTrolley,
error, Maybe<string>.None)
}
Let's revisit the steps to add a trolley item considering the aforementioned error type.
public async Task<Result<Trolley,TrolleyError>> AddToTrolley(
int shopperId, string productSku, int quantity)
{
var result = await (
from trolley in GetExistingTrolley(4311) // Result<Trolley, TrolleyError>
from product in GetRequestedProduct("2147") // Result<Product, TrolleyError>
let updatedTrolley = trolley.AddItem(new TrolleyItem(product, quantity))
from persistedTrolley in SaveTrolley(updatedTrolley) // Result<Trolley, TrolleyError>
from _ in TrackAnalytics(new AddToTrolley(product, quantity)) // Result<CartEventResponse, TrolleyError>
select persistedTrolley
);
return result;
}
private async Task<Result<Trolley,TrolleyError>> GetExistingTrolley(
int shopperId)
{
var existingTrolley =
await GetTrolleyForShopper(shopperId); // Result<Trolley, SqlError>
var result = existingTrolley.MapError(SqlError e => TrolleyError.FailedToFetchTrolley(e));
// returns Result<Trolley, TrolleyError>, the above can be simplified to
// existingTrolley.MapError(TrolleyError.FailedToFetchTrolley);
return result;
}
private async Task<Result<Trolley,TrolleyError>> GetRequestedProduct(
string sku)
{
Result<Maybe<Product, SqlError> productResult = await GetProductBySku(sku);
Result<Maybe<Product>, TrolleyError> productMaybe =
productResult.MapError(TrolleyError.FailedToFetchProduct);
var result = productMaybe.SelectMany(
Maybe<Product> p => p.HasValue
? Result.Success<Product,TrolleyError>(p.Value)
: Result.Failure<Product,TrolleyError>(TrolleyError.RequestProductMissing(sku))
);
return result;
}
private async Task<Result<CartEventResponse, TrolleyError>> TrackAnalytics(CartEvent cartEvent)
{
Result<CartEventResponse, HttpError> cartEvent = await TrackEventInAnalyticsStore(cartEvent);
Result<CartEventResponse, TrolleyError> result = cartEvent.MapError(TrolleyError.FailedToTrackCartEvent)
return result;
}
private async Task<Result<Trolley, TrolleyError>> SaveTrolley(Trolley trolley)
{
Result<Trolley, HttpError> trolley = await PersistTrolley(trolley);
Result<Trolley, TrolleyError> result = trolley.MapError(TrolleyError.FailedToPersistTrolley)
return result;
}
As always, lets examine what this approach gives us:
- Easy to build a catalog of errors As before, this gives us an easy way to filter all trolley related errors.
fetch logs
| filter Type == "TrolleyError"
We can see how this error model can be extended to include other facets of information.
fetch logs
| filter Type == "TrolleyError" and Domain == "Order"
This allows us to easily build a dashboards per application domain.
Now, is it strictly necessary to define an error type to achieve the aforementioned capability? The answer is no. Alternatively, we can define custom application exception types and throw these exceptions accordingly. However, there are drawbacks to this approach, which we will delve into in detail in a subsequent post. Nonetheless, I'd like to highlight two downsides here:
Using exceptions for control flow: When employed in this manner, are exceptions any different from a GOTO statement?
Visibility of potential errors, which we will examine in the following discussion.
- Errors part of the function definition
- Utilising the Result type, we handle errors as return values, enabling the caller to consume them and respond appropriately.
Result<Trolley, TrolleyError> result =
await AddToTrolley(shopperId: 4321, productSku: "2715", quantity: 5);
return result switch {
{IsSuccess: true } => NoContent() as IHttpActionResult, // 204 NoContent
_ => HandleFailure(result.Error)
}
IHttpActionResult HandleFailure(TrolleyError error)
{
return error.ErrorType switch {
RequestedProductMissing => BadRequest("Requested sku missing")
// We may decide that it is OK to miss an analytic event
// and not fail a request because of that
FailedToTrackCartEvent => NoContent()
_ => ServerError(500)
};
}
The caller is relieved from the burden of inspecting the function definition to ascertain which errors are thrown and under what circumstances.
This approach mirrors the practices embraced in modern languages such as Go and Rust, where errors are returned as values akin to the results of a function computation.
Moreover, it serves as a visual indicator to the consumer that this method is susceptible to failure, prompting them to handle errors with due consideration.
- Code readability
Have you encountered methods with numerous nested if statements and conditionals, making it challenging to discern the flow of execution?
Similarly, what about functions littered with more log statements than the actual business logic they perform?
These issues are so prevalent that Michael Feathers coined the following phrase in his excellent talk on unconditional code: Noticeable error handling is a symptom of bad design.
As the quote goes, we tend to read code more often than write it, it becomes imperative to organize code in a manner that enhances readability.
var result = await (
from trolley in GetExistingTrolley(4311)
from product in GetRequestedProduct("2147")
let updatedTrolley = trolley.AddItem(new TrolleyItem(product, quantity))
from persistedTrolley in SaveTrolley(updatedTrolley)
from _ in TrackAnalytics(new AddToTrolley(product, quantity))
select persistedTrolley
);
The above code has a cyclomatic complexity of 1.
At a quick glance, I get to see the different steps involved in adding a product to a trolley.
There are no visible error handling logic, though care is taken in each step to capture any possible errors.
We get to read the code in its happy path scenario, where each step is executed only if the previous step has succeeded.
Conclusion
I ended up writing a lot more than what I originally set out to, so we will wrap this one here.
We covered quite a lot of ground with this one.
We discussed why we needed a dedicated container type for representing errors.
We discussed how the Result type could co-exist with the Maybe type and how they complement each other.
We looked at how we can create strongly typed versions of failures by using the Result<T,E> type.
We saw how these strongly error instances allow us to get rich queryable logs through semantic logging.
We saw how to compose multiple functions returning Result types and what are some of the associated benefits.
In a subsequent post, we will see a concrete implementation for the validation library introduced in this post.