SchemaFactory: The Fix for Mutated Validation Rules Leaking Across ArkTS Forms
Shared Schema and SchemaFactory
Why changing one max affects another form? The shared instance problem in ArkTS validation rules
The registration page requires usernames to be at most 8 characters, the profile edit page allows 32. The rules are close, so extracting the common part seems like a few lines of code.
But there is an easily overlooked distinction between "reusing a rule definition" and "reusing the same rule instance."
This article, using my maintained HarmonyOS ArkTS validation library @hmkit/validator, explains how shared references affect rules, and how the 1.4.0 SchemaFactory organizes independent construction and derivation.
Example uses 1.4.0:
ohpm install @hmkit/[email protected]
1. Two variables may still be the same Schema
Look at this example:
import { liteV } from '@hmkit/validator/lite';
const base = liteV.string().required();
const shortName = base.max(8);
const longName = base.max(32);
console.info(`${shortName === longName}`); // true
console.info(`${shortName.validate('123456789').valid}`); // false
console.info(`${longName.validate('123456789').valid}`); // false
A 9-character string should satisfy "at most 32 characters", so why does longName also fail?
There are two layers to the reason:
- The current base chained methods modify the object itself and return the current instance. Here the three variables reference the same object.
.max()appends a rule. Calling.max(32)afterwards does not automatically delete the previous.max(8).
Therefore, this instance contains both maximum-length constraints, and the input must pass both. The 8-character limit still applies.
This is the behavioral convention of the current mutable API. When you need to maintain two different business rules, they should have independent instances.
2. First, build independent instances with a constructor function
The most direct solution is to reuse a function that creates the rule:
import { liteV, StringSchema } from '@hmkit/validator/lite';
function createNameRule(maxLength: number): StringSchema {
return liteV.string().required().max(maxLength);
}
const registerName = createNameRule(8);
const profileName = createNameRule(32);
console.info(`${registerName === profileName}`); // false
console.info(`${registerName.validate('123456789').valid}`); // false
console.info(`${profileName.validate('123456789').valid}`); // true
Here you reuse "how to create the rule", and each call creates a new StringSchema.
If the business only has two or three simple variants, a plain function is sufficient. As field combinations and derived configurations grow, you can also organize creation and derivation into a unified interface.
3. When creating a new outer object, also check internal references
Object derivation has another distinction.
partial() returns a new object Schema and independently tracks which fields are allowed to be missing; the internal fields may still reference the same sub-Schema:
import { liteV } from '@hmkit/validator/lite';
const sharedName = liteV.string().required();
const original = liteV.object({ 'name': sharedName });
const patch = original.partial();
sharedName.max(8);
const input: Record<string, Object> = {
'name': '123456789'
};
console.info(`${original === patch}`); // false
console.info(`${patch.validate({}).valid}`); // true
console.info(`${original.validate({}).valid}`); // false
console.info(`${original.validate(input).valid}`); // false
console.info(`${patch.validate(input).valid}`); // false
patch allows name to be missing, and it does not turn original's fields into optional. But when name appears, both still use sharedName for validation.
Therefore, "the outer Schema differs" is not enough to prove the whole mutable rule graph is isolated. For object fields, array elements, union branches, and recursive structures, you need to consider where the sub-rules are created.
4. Use SchemaFactory to organize construction and derivation
1.4.0 adds an optional entry point @hmkit/validator/reuse:
import { liteV, StringSchema } from '@hmkit/validator/lite';
import { SchemaFactory } from '@hmkit/validator/reuse';
const names = new SchemaFactory<StringSchema>(
(): StringSchema => liteV.string().required()
);
const shortNames = names.derive(
(schema: StringSchema): StringSchema => schema.max(8)
);
const longNames = names.derive(
(schema: StringSchema): StringSchema => schema.max(32)
);
const shortRule = shortNames.create();
const longRule = longNames.create();
console.info(`${shortRule.validate('123456789').valid}`); // false
console.info(`${longRule.validate('123456789').valid}`); // true
Its execution process can be divided into three steps:
- Save the base constructor.
derive()saves the derivation configuration; at this point the constructor is not executed.- Each
create()re-executes the base constructor, then applies the corresponding configuration.
In this way, variants can share creation logic, but each uses its own rule instance when actually validating. Derived configurations can also return Schemas of a different output type; the specific type is determined by the returned rule.
The construction and derivation callbacks may be executed multiple times, so they are suitable for rule creation and configuration logic. Side effects such as calling an API or writing external state should be handled in the corresponding business flow.
5. Sub-rules should also be created inside the constructor
For example, a profile object contains a name and an array of tags, and can be organized like this:
import { liteV, ObjectSchema } from '@hmkit/validator/lite';
import { SchemaFactory } from '@hmkit/validator/reuse';
function createProfileSchema(): ObjectSchema<Record<string, Object>> {
return liteV.object({
'name': liteV.string().required(),
'tags': liteV.array(liteV.string().required())
});
}
const profiles = new SchemaFactory<ObjectSchema<Record<string, Object>>>(
createProfileSchema
);
const firstProfile = profiles.create();
const secondProfile = profiles.create();
Here, the object, the name rule, the array, and the array element rules are all re-created on each construction.
Conversely, if the callback merely returns an externally existing instance:
const shared = liteV.string().required();
const reused = new SchemaFactory<StringSchema>(
(): StringSchema => shared
);
Multiple creates will still return shared. SchemaFactory executes the constructor you provide; it does not automatically copy the object it returns.
Similarly, mutable default objects, recursive graphs, and external state captured by custom callbacks need to be managed according to actual usage.
6. Why adopt the re-construction approach
Schemas may contain functions, plugins, metadata, and recursive references, and a simple JSON serialization cannot preserve these execution semantics.
On the other hand, the original chain methods use a mutable convention, and directly changing all methods to return new instances would affect existing call patterns.
1.4.0 uses an explicit factory, letting business variants that need isolation start from a fresh rule graph while preserving the old API's calling habits. It provides isolation implemented by construction convention; a generic deep copy of an arbitrary existing Schema still needs separate design.
In my project, I recommend putting rule constructor functions in an independent module and creating instances per scenario; when you need to manage variants collectively, use SchemaFactory. When checking reuse issues, besides comparing the outer object, also verify the source of mutable sub-rules.
Open source address and notes
Welcome to report issues with reproducible rule combinations and input data, especially for reuse scenarios with nested rules, recursive structures, and custom plugins.