A Record in Octarine is a heterogeneous map, whose keys carry type information about their values. The only type guarantee you get is that if a Record contains a Key<T> , then the value pointed to by that key will be of type T:
1 2 3 4 5 6 7 |
Key<String> name = Key.named("name"); Key<Integer> age = Key.named("age"); Record person = Record.of(name.of("Alceste"), age.of(53)); String personName = name.extract(person); Integer personAge = age.extract(person); |
Some additional safety can be given by validating a Record against a Schema<T> . If the validation is successful, a Valid<T> can be obtained, which is a Record ornamented with a type tag linking it to the schema that validated it. You can then enlist the help of the compiler in ensuring that code that will only work with a Valid<T> is only ever passed a Valid<T> .
Here’s how it works:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
interface Person { static final Key<String> name = Key.named("name"); static final Key<Integer> age = Key.named("age"); static final Schema<Person> schema = (record, validationErrors) -> { if (!name.test(record)) validationErrors.accept("Person must have a name"); if (!age.test(record)) validationErrors.accept("Person must have an age"); if (age.extract(record) < 0) validationErrors.accept("Age must be greater than or equal to 0"); }; } Validation<Person> result = Person.schema.validate(Person.name.of("Alceste"), Person.age.of(53)); // Throws an exception if the record did not pass validation. Valid<Person> person = result.get(); // Formatter will only accept a Valid<Person> Function<Valid<Person>, String> formatter = p -> String.format("%s, age %d", Person.name.extract(p), Person.age.extract(p)); System.out.println(formatter.apply(person)); |
The Person interface here is never instantiated. It has two purposes:
- To act as a holder for the Key s and Schema associated with Person s
- To be used as a type tag identifying Record s that have passed validation as instances of Valid<Person>
The Schema<Person> is created from a lambda expression that consumes a record and a consumer of validation errors, to which it can send strings describing any errors that it detects. In the example above, we test for the presence of two keys, and apply an additional test to the value of the “age” key.
You can call Validation::isValid to find out whether validation has succeeded, and Validation::validationErrors to get a list of all the validation errors that have been detected. Only if the list of validation errors is empty will Validation::get return a Valid<T> ; otherwise a RecordValidationException will be thrown.
The tests for the presence of mandatory keys are a bit cumbersome, so you can use a KeySet to check that all the keys belonging to Person are present:
1 2 3 4 5 6 7 8 9 10 11 12 |
public interface Person { static final KeySet mandatoryKeys = new KeySet(); static final Key<String> name = mandatoryKeys.add("name"); static final Key<Integer> age = mandatoryKeys.add("age"); static final Schema<Person> schema = (record, validationErrors) -> { mandatoryKeys.accept(record, validationErrors); if (age.extract(record) < 0) validationErrors.accept("Age must be 0 or greater"); }; } |
Obviously, the compiler can’t guarantee that validation will succeed, since the tests carried out by the schema are only executed at runtime. However, you can subsequently guarantee that other code will only be passed a record that has been validated, by requiring a type of Valid<T> rather than just a plain Record . This is how Octarine’s validation provides a bridge between the type-unsafe world of external data, and the type-safe world of your program’s domain model.