Type Lifecycle
A C3 AI Type is akin to a class in Java, and describes data fields, operations and static functions that can be called on the Type by one or more applications. The difference is that a Type is not tied to any particular programming language.
Obj and mixins
Most values are defined by Types. Everything except the most basic primitive types (such as int and string) is defined by a Type. Types can define a directly usable object, or they can represent a slice of functionality that can be used by different objects. For example, the Duration Type provides useful ways to interact with elapsed time; it in turn includes the abstract Type mixin StringSerializable that declares functionality for converting instances to and from strings.
A duration is a measurement of elapsed time in microseconds.
type Duration mixes StringSerializable {
// ...
}Base Type for Types that implement custom string serialization.
abstract type StringSerializable mixes WithToString, Value {
// ...
}Most Types define the Type Obj, which has fields and many conveniences. Obj is a Type that itself mixes in several Types that bring in several slices of functionality. Obj is a convenient package, but is itself a collection of more specific features that can also be used by other Types.
Composition, one of the fundamental concepts in object-oriented programming, is a key aspect of Types. Composition involves using other Types to build more complex Types to achieve polymorphic behavior and promote code reuse. Basically, complex objects are composed of other objects.
Several key mixins used by Obj are worth noting:
With mixins, you can mix in functionality (fields/attribute values, methods) from existing, already built Types into new Types that you build yourself - rather than recoding that functionality from scratch.
WithType
Any Type that mixes WithType declares that it provides the type method. This means that one can get introspection into an instance's Type by calling o.type(). This is true for not only full Objs, but for most complex objects in the system, including collections.
Serializable
The ability to export the entire state of an instance as JSON for sending to a remote instance is a fundamental feature of Obj. And for Obj, its implementation is recursively serializing the Type and all field values as JSON. For example, the Duration Type has a single field, and its JSON representation for a 1.5s duration would be { "type": "Duration", "microseconds": 1500000 }.
Illustrating the first common pattern, Serializable declares the member function toJson and the static function fromJson. The Type is used to instantiate an instance of that Type from its JSON representation, but produces that JSON representation from an instance itself.
Incomplete objects can also be serialized and de-serialized, see Validation below. The fromJson method can only fail if the JSON cannot be understood.
The JSON representation might not be exactly the same as what it specified when constructing an object, due to boxing or use of the Ann.Ser annotation. For more information, see Serializing and deserializing data.
Creation from fields
The natural method of Obj creation differs from language to language, but is always based on specifying the field values.
In Java, one way to construct complex objects is to use the builder pattern
Snake.builder()
.name("garter")
.color("brown/yellow")
.build();Each of the above object initializers creates an instance of the Snake Type with two fields name and color hydrated with sample data. The JSON object for these instances is:
{
"type": "Snake",
"name": "garter",
"color": "brown/yellow"
}This allows interoperability between languages, even across processes.
Before/After hooks
Obj declares the methods beforeMake and afterMake. Since instances are immutable, these hooks allow private state to be set up before the object is frozen.
For example MutableObj, implements tracking of state changes by setting up private state in the method afterMake.
function afterMake() {
Object.defineProperty(this, '$listeners', {
value: [],
writable: true
});
return this;
}Note that these should not be used for validation. Objects must be capable of be being instantiated in an incomplete state, so forcing validation in the process of creation itself would break incremental loading or setting them up in multiple steps.
These hooks should not be used to setup default values, see below.
Default values
A related function is Obj#withDefaults. Obj#withDefaults returns a new instance that is immutable. The unspecified fields in the new instance are set to the default values as specified in the Type declaration:
type CsvSpec mixes Spec {
colSep: ?string = ','
}This can be used to explicitly fill in the default values for any fields that have not been set or have been cleared. Usually, Types that are meant as a collection of options should mixin Spec, which automatically causes the default values to be populated so the receiver does not have to worry about it.
If an instance requires values that are not specified through the default values of the field, you can override the method by calling the "super" version for the standard behavior.
function withDefaults() {
let complete = this.super().withDefaults();
// make sure we have a reasonable number of iterations if we're using an optimization method
if (complete.method && complete.iterations == null) {
complete = complete.withIterations(Optimize.minIterations(complete.method));
}
return complete;
}Non-Obj instances
Not everything is an Obj. Types that are based on data other than fields may also be declared by Types, but they won't mixin Obj. For example, Array is not an Obj but mixes in and provides implementations for WithType and Serializable.
Value
By default, a Type defines an Obj except if it has an explicit mixin of Value. This represents the most primitive form of a Type and is just a marker to prevent a Type from automatically mixing in Obj. This is used by Types that represent complex structure, but can't obey the Obj contract. For example, Collection is the base Type for collections, but they are not Objs and thus they mixin Value as a marker of that fact.
Another example is Obj itself: Obj mixes the Type Value because it itself defines Obj and, therefore, cannot mix itself in.
StringSerializable
Many objects have a natural representation as a string. Obvious ones are Url and ContentType, but many things fall into this category, including the Duration Type mentioned above. Obj does not mixin StringSerializable by default, so Duration does so explicitly.
If a Type mixes both Serializable and StringSerializable, all serialization and deserialization methods must use and produce the same string representation.
For example, Type mixes both, and its serialization is just its name (possibly including generic bindings).
Just as Serializable declares toJson and fromJson, StringSerializable declares toString and fromString. A common pattern begins to emerge where alternate representations of instances can be created.
There is special Type declaration support for StringSerializable:
type Endpoint {
url: string serialized Url
}The type of this field is the primitive string, because that's the natural representation of a URL. However, often you want to use the methods defined by Url, so the system automatically creates the method urlParsed(): !Url on the Type (the same as the field name, with "Parsed" appended).
This means the value can be passed around as a string, and also use the higher-level functions:
e.urlParsed().scheme.
Like fromJson, the fromString method only fails if the string is an invalid format (cannot be understood); it does not require the object to be complete. See the section on Validation for more details.
Validation
It is possible to construct instances that do not conform to constraints. For example, a method may specify that one of its parameters is non-empty: concat: function(s: ![string]). This means that calling this method with a null value or an an empty array (nor, for that matter, with an array of something other than strings) can cause an error.
However, it is clearly possible to create an empty array of strings, which would be valid in other contexts. A more interesting example is a Type that declares a field with a constraint:
type Contact {
addresses: ![Address]
}In this case, an instance of Contact must have at least one address, Below is an instance of Contact without an address created:
c3.Contact()This succeeds and returns an immutable instance with an empty list of addresses. This instance is not valid and calling Obj#validateObj would throw an error. However, creating the value as an intermediate form of processing is necessary. There may be multiple steps in the creation of a contact and so an incomplete instance must be allowed.
For example, the method addCanonicalAddress has been added to Contact:
type Contact {
addCanonicalAddress: member function(entered: !?string): !mixing Contact
}addCanonicalAddress must work for an instance of Contact without any existing addresses. This means method implementations cannot assume an instance they receive is valid, and they should validate it if necessary (for example for storage).
Note also that one can use an Include include spec to load a subset of the data from a database. In fact, this is considered good practice for efficient processing. In this case, fields that are not fetched have no value, even if the persisted object stored values for them.
See also Spec and Result for other Types that provide additional semantics when used as arguments and return values of methods. These always have default values populated when received by the method and are always validated.
If a method wants to mark its parameters as requiring validation, that can be done with the @validate annotation. For example:
distribute: function(@validate contact: !Contact)This would force the system to validate the Contact instance before making the call to distribute. Note that even without the annotation, a null value would not be allowed (because the parameter is non-empty), but full validation would not be performed (in this case using Obj#validateObj). This is the default behavior for types that mixin Spec.
A method can also mark its return value with this annotation and the system validates it before the return value is received by the caller. This is the default behavior for types that mixin Result.
Validation can even be customized. If a Type overrides validateObj, it can call the super implementation plus add more checks as needed.
Validation is also supported for non-Obj values, see ValueType#validateValue.