Skip to main content

Validation

A wrong value in an infrastructure definition is cheap to write and expensive to find. An empty string where a bucket name should be, a subnet ID passed where a VPC ID was wanted, a CIDR block with a typo: all of them are valid text, and all of them surface when a provider rejects them, partway through an apply, with half a stack standing.

@Validate puts the check on the property itself. It runs when the value is assigned, which for a module's inputs is before the module runs and before any Terraform is written.

@Validate({ options: { minLength: 1 } })
queueName = Schema<string>();

The constraints​

ConstraintPasses when
minLengthA string or array is at least this long; a number is at least this value. Not for booleans.
maxLengthThe same, as an upper bound.
regexA string matches the pattern.
isModelThe value is a model with the given NODE_NAME, and publishes the anchors you list.
isOverlayThe value is an overlay with the given NODE_NAME, optionally a specific overlayId, and publishes the anchors you list.
isResourceThe value is a resource with the given NODE_NAME, optionally a specific resourceId.
isSchemaThe value's serialized form satisfies a schema.
customYour own function returns true.

minLength and maxLength measure a string or an array by its length and a number by its value, which is why minLength: 1 on a number reads oddly and means "at least 1". On a plain object they return false, always. That is not a bug to work around; it is why the destruct below exists.

Neither belongs on a boolean. They measure the printed word, so "true" is 4 and "false" is 5, and minLength: 1 accepts both and checks nothing. A boolean that must be supplied is already required by Schema<boolean>() with no default, and a missing one fails as could not be resolved before any rule runs.

Checking a node, not a string​

The one worth understanding properly is isModel, because it is what makes module composition safe:

@Validate([
{
options: {
isModel: { anchors: [{ schema: AwsRegionAnchorSchema }], NODE_NAME: 'region' },
},
},
{
destruct: (value: ShopQueueServiceModuleSchema['region']): [RegionSchema] => [value.synth()],
options: { isSchema: { schema: RegionSchema } },
},
])
region = Schema<Region>();

Read the first rule as: this must be a node registered under the name region, and it must publish an anchor satisfying AwsRegionAnchorSchema. Not a string that looks like a region ID, and not any old region either, but one carrying the AWS details this module needs.

The second checks the serialized form against RegionSchema, which catches a node of the right name whose contents are wrong.

NODE_PACKAGE can be added to require a specific library's node, and it usually should not be. The whole point of matching on shape is that somebody else's region works too.

Validating inside a value​

destruct reduces a property to the list of values the constraints run against. Without it the property is checked as-is, and a minLength against an object fails whatever the object contains:

@Validate({
destruct: (value: ShopQueueSchema['properties']): string[] => [
value.awsAccountId,
value.awsRegionId,
value.queueName,
],
options: { minLength: 1 },
})
override properties = Schema<{ /* ... */ }>();

Three fields, one rule, applied to each. This is the pattern in every resource schema: pull out the strings that must not be empty, and check them together.

For a list of nested objects, destruct to the list and check each against a schema:

@Validate({
destruct: (value: AwsServerModuleSchema['s3']): AwsServerS3AccessPrefixSchema[] =>
value!.map((v) => v.accessPrefixes).flat(),
options: { isSchema: { schema: AwsServerS3AccessPrefixSchema } },
})
s3 ? = Schema<{ accessPrefixes: AwsServerS3AccessPrefixSchema[]; service: Service }[]>([]);

Several rules on one property​

Pass an array, and every rule has to pass. They can use different destruct functions, which is how the s3 input above checks the access prefix grants, the services, and the services' serialized forms in three separate passes over the same value.

Transforming before checking​

@Validate takes a second argument that runs first and replaces the stored value:

@Validate({ options: { regex: /^[a-z0-9-]+$/ } }, (value: string) => value.trim().toLowerCase())
queueName = Schema<string>();

Use it for normalization, not for repair. A transform that quietly fixes a wrong value hides a mistake the caller should have been told about, and the value it stores is what everything downstream sees.

What a failure looks like​

Validation throws a ValidationTransactionError, and through a module schema it surfaces as:

Error: Property "region" in schema could not be validated!
schemaClassName: AwsS3StorageServiceModuleSchema

The schema class name is the useful half: it names the module whose input was rejected. Nothing has been written at this point, and nothing has been created.

Where to put a check​

Validation is for what can be checked about a value on its own, or against the graph. It is not for what can only be checked against the world.

A rule belongs on the schema when it is about shape: a name matching a pattern, a list that must not be empty, a node of the right kind. Those are cheap and they run everywhere the schema is used.

A rule belongs in the module's onInit when it needs more than one input, or a lookup:

if (environment.getParents()['region'][0].to.getContext() !== subnet.getParents()['region'][0].to.getContext()) {
throw new ModuleError('Environment and Subnet must be in the same region!', this.constructor.name);
}

Both fail before anything is created. The difference is only how much context the check needs.