-
Notifications
You must be signed in to change notification settings - Fork 0
Customization
By default, the result of applying clone or equals to an instance of a user-defined class is the same as applying it to any other (non-exotic) object: i.e. equals will compare an instance of a class equal to another value if and only if the other value is an object of the same class with the same property keys and values, and clone will return a new instance of the class with cloned property values.
It is, however, possible to customize these results for user-defined classes using decorators included in value-semantics, as described below. In this document, by a clone or equals implementation for a class, I mean the algorithm used to determine the results of calling clone or equals on an instance of that class. The default implementation is then the algorithm which applies without any customization, i.e. the one described above. Read on to find out how clone or equals implementations can be customized for user-defined classes.
@customize.clone(
semantics?: CloneSemantics = 'deep',
options?: CustomizeCloneOptions | IterateCloneOptions = {}
)The @customize.clone class decorator can be used to customize the clone implementation for a class. When called with no arguments, or (one or both) default arguments, the decorated class will have the default implementation (i.e. @customize.clone will be a no-op). Otherwise, the clone implementation can be customized in the following ways:
type CloneSemantics = 'deep' | 'iterate' | 'returnOriginal' | 'errorOnClone';The semantics parameter can be used to customize the semantics of the class' clone implementation. There are 3 kinds of semantics that a clone implementation can have:
-
'deep': This is the default semantics, whereclonereturns a deep clone of the class instance. -
'iterate': With this semantics,clonegenerates a new instance by iterating through the original and adding each result using theaddMethodmethod specified inoptions. Note that if eitherSymbol.iteratoror the specifiedaddMethodmethod are not defined on the class, then aValueSemanticsErrorwill be thrown at class definition. -
'returnOriginal': With this semantics,clonewill return the original class instance without any cloning being performed. -
'errorOnClone': With this semantics,clonewill throw aValueSemanticsErrorat runtime when applied to a instance of this class.
type CustomizeCloneOptions = {
runConstructor?: boolean = false,
propDefault?: 'include' | 'exclude' = 'include'
}If a class' clone implementation has 'deep' semantics, then it can be further customized using the options parameter. There are two properties which can be specified using the options parameter:
By default, and when this property is false, clones of a class instance are created by Object.create(), before the instance's properties are copied over. When this property is true, clones of a class instance are created by the class' constructor. Arguments for the constructor call can be specified using the @clone.constructorParam decorator described below, otherwise the constructor will run without any arguments.
For example:
@customize.clone({ runConstructor: true })
class Graph {
public nodes: Nodes[];
public edges: Edge[];
constructor() {
this.nodes = [];
this.edges = [];
}
}
const originalGraph = new Graph();
const clonedGraph = clone(originalGraph);
// calls Graph.prototype.constructor()TIP: Note that a 'simple' clone of an instance, i.e. one that just copies over all of the instance's properties without running the constructor, will maintain all of the class' invariants, as the clone's properties are identical to the original's, and the original already maintained those invariants. Thus, constructors that just run validation tests on constructor arguments do not need to be run for clones.
See the note under
@clone.constructorParamfor more about using constructors to clone instances.
By default, and when this property is 'include', every (own, enumerable) property of an instance of the class will be copied over to its clones, unless otherwise specified using the @clone.exclude (or @value.exclude) decorator described below.
In contrast, when this property is 'exclude', no properties of an instance of the class will be copied over to its clones, unless otherwise specified using the @clone.include (or @value.include) decorator described below.
type IterateCloneOptions = {
addMethod: string | symbol,
runConstructor?: boolean = false
}If a class' clone implementation has 'iterate' semantics, then it must be further customized using the options parameter. There are two properties which can be specified using the options parameter:
This property specifies the property key of the a class method to be used add members to the cloned instance. The idea is that, after iterating through the original instance and adding each result to the cloned instance using the method specified by this property, the cloned instance will value-equal the original. If the specified key is undefined on the class, then a ValueSemanticsError will be thrown at class definition. To work properly, this method should have the signature (member: M): void, where M is the type of the members of the class. This property is mandatory.
This property has the same meaning and defaults as the runConstructor property for 'deep' semantics.
The options parameter cannot be passed for classes with 'returnOriginal' or 'errorOnClone' semantics, as those semantics have no additional customization options.
@clone.constructorParamOn a class with 'deep' clone semantics and runConstructor: true, labelling a class field with @clone.constructorParam means that the value of that field will be provided to the constructor as an argument when cloning an instance of the class.
On any other class, @clone.constructorParam has no effect.
If multiple fields are labelled with @clone.constructorParam, they will be provided to the constructor in order, top to bottom.
For example:
@customize.clone({ runConstructor: true })
class Person {
@clone.constructorParam private height: number;
@clone.constructorParam private age: number;
constructor(height: number, age: number) {
this.height = height;
this.age = age;
}
}
const originalPerson = new Person(178, 36);
const clonedPerson = clone(originalPerson);
// calls Person.prototype.constructor(178, 36)DISCUSSION: With
runConstructor, there are three kinds of class whose instances can be cloned:
- Classes whose instances don't need their constructor to be run to be created.
- Classes whose constructors don't require any arguments.
- Classes whose constructors require arguments, but the arguments needed to construct the clone of an instance are all properties of that instance.
@clone.constructorParamis needed for the 3rd kind of class. What sort of properties are these? Well, they may be properties that never change throughout the lifetime of an instance. Or they might be properties that change, but change in such a way that they match the arguments you would use to create a brand-new instance of the class which behaves identically to the original instance. Basically, the question is whether an instance holds the state necessary to use its constructor to recreate that instance in the form of properties on that instance which match the parameters of the constructor. If so, then the class can be made clonable by decorating those properties with@clone.constructorParam. If the instance does hold that state, but not in that form, then such properties could be added to the class to make it clonable. If that state is not held by the instance at all, then the class is not clonable.
On a class which also has propDefault: include, labelling a class field with @clone.constructorParam will cause that field to be excluded from the clone implementation by default, unless the field is also labelled with @clone.include (or @value.include).
DESIGN NOTE: The reason that
@clone.constructorParamfields are excluded from cloning by default is that it is expected that such fields are already set in the constructor, and therefore also cloning them would be redundant.
@clone.exclude
@clone.includeOn a class with 'deep' clone semantics and propDefault: include, decorating a class field with
@clone.exclude will override the default and exclude that field when cloning an instance of the class.
On any class with 'deep' clone semantics, decorating a field with both @clone.exclude and
@clone.include (or @value.include) will throw a ValueSemanticsError at runtime on class definition.
Otherwise, @clone.exclude has no effect.
On a class with 'deep' clone semantics and propDefault: exclude, decorating a class field with
@clone.include will override the default and include that field when cloning an instance of the class.
On a class with 'deep' clone semantics, propDefault: include and runConstructor: true,
decorating a @clone.constructorParam field with @clone.include in addition will override the
default and include that field when cloning an instance of the class (in addition to it being a
constructor parameter).
On any class with 'deep' clone semantics, decorating a field with both @clone.include and @clone.exclude (or @value.exclude) will throw a ValueSemanticsError at runtime on class definition.
Otherwise, @clone.include has no effect.
@customize.equals(
semantics?: EqualsSemantics = 'value',
options?: CustomizeEqualsOptions = {}
)The @customize.equals class decorator can be used to customize the equals implementation for a class. When called with no arguments, or (one or both) default arguments, the decorated class will have the default implementation (i.e. @customize.equals will be a no-op). Otherwise, the equals implementation can be customized in the following ways:
type EqualsSemantics = 'value' | 'ref' | 'iterate';The semantics parameter can be used to customize the semantics of the class' equals implementation. There are 2 kinds of semantics that an equals implementation can have:
-
'value': This is the default semantics, whereequalcompares two instances of the class as equal if and only if they are value-equals (roughly, when they have the same property values). -
'ref': With this semantics,equalcompares two instances of the class as equal if and only if they refer to the same instance. In other words, on this semanticsequalsis the same as===. -
'iterate': With this semantics,equaliterates through the two instances being compared, and compares the instances as equal if they have the same number of members, and each pair of members is value-equal. Note that if eitherSymbol.iteratoris not defined on the class, then aValueSemanticsErrorwill be thrown at class definition.
type CustomizeEqualsOptions = {
propDefault?: 'include' | 'exclude' = 'include'
}If a class' clone implementation has 'value' semantics, then it can be further customized using the options parameter. The options parameter cannot be passed for classes with 'ref' or 'iterate' semantics, as it has no additional customization options. There is one property which can be specified using the options parameter:
By default, and when this property is 'include', every (own, enumerable) property of
an instance of the class will used to compare instances for equality, unless otherwise specified
using the @equals.exclude (or @value.exclude) decorator described below.
In contrast, when this property is 'exclude', no properties of an instance of the class will be used to compare instances for equality (meaning all instances of the class compare as equal), unless otherwise specified using the @equals.include (or @value.include) decorator described below.
@equals.include
@equals.excludeOn a class with 'value' equals semantics and propDefault: exclude, decorating a class field with
@equals.include will override the default and include that field when making equality comparisons.
On any class with 'value' equals semantics, decorating a field with both @equals.include and
@equals.exclude (or @value.exclude) will throw a ValueSemanticsError at runtime on class definition.
Otherwise, @equals.include has no effect.
On a class with 'value' equals semantics and propDefault: include, decorating a class field with
@equals.exclude will override the default and exclude that field when making equality comparisons.
On any class with 'value' equals semantics, decorating a field with both @equals.exclude and
@equals.include (or @value.include) will throw a ValueSemanticsError at runtime on class definition.
Otherwise, @equals.exclude has no effect.
@customize.value(
cloneSemantics?: CloneSemantics = 'deep' // cloneSemantics and equalsSemantics can be
equalsSemantics?: EqualsSemantics = 'value' // in either order
options: CustomizeValueOptions = {}
)It is possible to customize the implementations of both equals and clone on a class by decorating it with both @customize.equals and @customize.clone. However, to save time, this library also provides the @customize.value decorator, which customizes both of these functions simultaneously.
When called with no arguments, or (one, two or all of the) default arguments, the decorated class will have the default implementations for equals and clone (i.e. @customize.value will be a no-op). Otherwise, the equals and clone implementations can be customized in the following ways:
Note that the cloneSemantics and equalsSemantics can be provided in either order, although if one or both of them are present they must come before the options parameter (if it is present).
The cloneSemantics parameter can be used to customize the semantics of the class' clone implementation. The values that this parameter can take, and their meanings, are the same as for the semantics parameter of the @customize.clone decorator. NOTE: 'iterate' cannot currently be specified via this parameter.
The equalsSemantics parameter can be used to customize the semantics of the class' equals implementation. The values that this parameter can take, and their meanings, are the same as for the semantics parameter of the @customize.equals decorator. OTE: 'iterate' cannot currently be specified via this parameter.
type CustomizeValueOptions = {
runConstructor?: boolean = false,
propDefault?: 'include' | 'exclude' = 'include'
}If a class' has 'deep' clone and/or 'value' equals semantics, then it can be further customized using the options parameter. Note that the options parameter cannot be passed for classes with 'ref' equals and 'returnOriginal' or 'errorOnClone' clone semantics, as those semantics have no additional customization options. There are two properties which can be specified using the options parameter:
The values that this property can take, and their meanings, are the same as for the runConstructor parameter of the @customize.clone decorator. As in the @customize.clone case, arguments for the constructor call can be specified using the @clone.constructorParam decorator described above, but note that there is no @value.constructorParam decorator. Note also that this property cannot be specified if the class has 'returnOriginal' or 'errorOnClone' clone semantics, as those semantics cannot involve running a constructor. This is true even if the class has 'value' equals semantics, and can therefore take an options argument.
The values that this property can take, and their meanings, are the same as for the propDefault parameters of the @customize.clone and @customize.equals decorators. In other words, setting propDefault to 'include' ('exclude') is equivalent to setting propDefault to 'include' ('exclude') on both @customize.clone and @customize.equals. Note that @customize.value does not allow different values to be set for propDefault for clone and equals implementations. To do so, you would have to use seperate @customize.equals and @customize.clone decorators. Note also that, given a propDefault: 'include' value, decorating a class field with @clone.constructorParam will exclude that property from cloning but not equality comparisons. This can be overridden by either the @clone.include or @value.include decorators (but not @equals.include).
@value.include
@value.excludeDecorating a class field with @value.include (@value.exclude) has the same effect as decorating that field with both @clone.include and @equals.include (@clone.exclude and @equals.exclude). This means that, for example, decorating a class field with @value.include on a class decorated only with @customize.equals({ propDefault: exclude }) will have the same effect has decorating that field with just @equals.include (i.e. the @clone.include aspect is a no-op).
Any combination of field decorators which leads to a field being decorated with both @clone.include and @clone.exclude, and/or @equals.include and @equals.exclude, will throw a ValueSemanticsError at runtime on class definition. For example, decorating a field with @value.exclude and @clone.include will lead to such an error (even if the @clone.exclude aspect of @value.exclude is otherwise a no-op).
On a class with 'deep' clone semantics, propDefault: include and runConstructor: true, decorating a @clone.constructorParam field with @value.include in addition will override the default and include that field when cloning an instance of the class (in addition to it being a constructor parameter).