Skip to content

Customization

simonvarey edited this page Apr 6, 2025 · 4 revisions

Customizing Cloning and Equality for User-defined Classes

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.

Customizing clone Implementations

@customize.clone Class Decorator

@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:

semantics Parameter

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, where clone returns a deep clone of the class instance.
  • 'iterate': With this semantics, clone generates a new instance by iterating through the original and adding each result using the addMethod method specified in options. Note that if either Symbol.iterator or the specified addMethod method are not defined on the class, then a ValueSemanticsError will be thrown at class definition.
  • 'returnOriginal': With this semantics, clone will return the original class instance without any cloning being performed.
  • 'errorOnClone': With this semantics, clone will throw a ValueSemanticsError at runtime when applied to a instance of this class.

options Parameter

'deep' Semantics
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:

runConstructor Property

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.constructorParam for more about using constructors to clone instances.

propDefault Property

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.

'iterate' Semantics
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:

addMethod Property

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.

runConstructor Property

This property has the same meaning and defaults as the runConstructor property for 'deep' semantics.

Other Semantics

The options parameter cannot be passed for classes with 'returnOriginal' or 'errorOnClone' semantics, as those semantics have no additional customization options.

@clone.constructorParam Class Field Decorator

@clone.constructorParam

On 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:

  1. Classes whose instances don't need their constructor to be run to be created.
  2. Classes whose constructors don't require any arguments.
  3. Classes whose constructors require arguments, but the arguments needed to construct the clone of an instance are all properties of that instance. @clone.constructorParam is 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.constructorParam fields 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.include Class Field Decorators

@clone.exclude
@clone.include

@clone.exclude Class Field Decorator

On 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.

@clone.include Class Field Decorator

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.

Customizing equals Implementations

@customize.equals Class Decorator

@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:

semantics Parameter

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, where equal compares 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, equal compares two instances of the class as equal if and only if they refer to the same instance. In other words, on this semantics equals is the same as ===.
  • 'iterate': With this semantics, equal iterates 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 either Symbol.iterator is not defined on the class, then a ValueSemanticsError will be thrown at class definition.

options Parameter

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:

propDefault Property

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.exclude/@equals.include Class Field Decorators

@equals.include
@equals.exclude

@equals.include Class Field Decorator

On 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.

@equals.exclude Class Field Decorator

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.

Customizing clone and equals Implementations Simultaneously

@customize.value Class Decorator

@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:

cloneSemantics/equalsSemantics Parameters

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).

cloneSemantics Parameter

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.

equalsSemantics 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.

options Parameters

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:

runConstructor Property

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.

propDefault Property

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.exclude/@value.include Class Field Decorators

@value.include
@value.exclude

Decorating 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).