Type Transformers

TypeScript allows you to transform the shape of types via special generics like Partial<...>, as well as special syntax, like mapped type syntax. Moat Maker chose to go a simpler and more lightweight route of allowing you to write arbitrary code to transform a validator type.

When a validator instance is created, the "rules" it follows can be found in the ruleset property of the validator instance. A ruleset is an object of type Ruleset that contains two fields, a rootRule property, which is the root node of a tree of rules, and an interpolated property, which contains a list of everything that was interpolated into the template. The entire ruleset is deeply frozen to prevent mutation.

A transformer is simply a function that is capable of transforming a ruleset into another ruleset. You can then feed your transformed ruleset into validator.fromRuleset() to produce a new validator instance patterned after those rules.

Stability Warning: Treat type transformers as a last resort. They tend to be somewhat fragile and may break between updates. As new features come out, additional rule types may be added, and new fields could be added to existing rule types, or worse, the shape of existing rules will have to be changed. These changes can cause existing transformers to either completely break, or to not properly fulfill their role anymore.

Example

We are going to make a transformer that, when given a validator that validates an array of X, it will extract the X and turn that into a new validator. For example, typeOfArrayContent(validator`string[]`) will be equivalent to validator`string`.

Let's first take a peek at what an array rule looks like. Documentation for information like this can be found under "Rule Structure" sections in the Syntax Reference. We can also simply create a validator instance ourselves, check the ruleset property, and see what comes out.

> validator`string[]`.ruleset;
{
  rootRule: {
    category: "array",
    content: {
      category: "simple",
      type: "string"
    }
  },
  interpolated: []
}

This shows us that the shape of an array rule is fairly simple. It will have a category property set to "array", and a content property set to an arbitrary rule. Thus, a transformer will simply need to:

  1. verify that the passed-in ruleset represents an array

  2. Extract the "content" from the array rule

  3. Built the new ruleset. Make sure to preserve the interpolated array, in case the extracted content needs those interpolated values.

Here's what the end result looks like:

function typeOfArrayContent(ruleset) {
  // 1. Verify that the passed-in ruleset represents an array
  if (ruleset.rootRule.category !== 'array') {
    throw new Error('This transformer only accepts array types.');
  }
  
  // 2. Extract the "content" from the array rule
  const contentRule = ruleset.rootRule.content;

  // 3. Built the new ruleset
  return {
    rootRule: contentRule,
    // Preserve the original interpolated array
    interpolated: ruleset.interpolated,
  };
}

// ✓
validator.fromRuleset(
  typeOfArrayContent(validator`string[]`.ruleset)
).assertMatches('my string');

// ✓
validator.fromRuleset(
  typeOfArrayContent(validator`${42}[]`.ruleset)
).assertMatches(42);

In this scenario, we chose to make the transformer accept a ruleset as an argument. Don't take this as some established convention. You can choose to make them take a validator instance as an argument, and have it return a new validator. Or, if it makes sense, you can choose to make it take a rule (instead of a ruleset) as an argument.

Last updated