Skip to content

addInvariant and removeInvariant

Invariants are cross-field validation rules that run after structural validation succeeds - the json-tology equivalent of Pydantic's @model_validator(mode='after'). They integrate with validate(), instantiate(), and is().


JsonTology.addInvariant

Declaration. Registers an InvariantInterface<T> for the schema identified by schemaId. The invariant's fn function receives a fully structural-validated (clean, defaults-applied) object and returns null or undefined on success, or an error message string on failure. An optional pointer JSON Pointer string pins the error to a specific field path. Invariants run in registration order after structural validation passes.

Use this when a business rule involves two or more fields and cannot be expressed as a single-field JSON Schema keyword. Examples: total must equal sum(items[].unitPrice * quantity), a date range must have start <= end, a 5-star review requires a long body.

Don't use this when the constraint can be expressed as a single JSON Schema keyword (minimum, maxLength, pattern, etc.) - structural constraints are faster and run first. Don't confuse with computed fields - invariants validate, computed fields derive.

Examples

Example 1: Order total must match line items

/**
 * addInvariant — Example 1: Order total invariant
 * Demonstrates: canonical invariant registered on OrderSchema, validation with
 * invariant failure, and passing valid fixture
 *
 * Operates against the canonical bookstore registry. The production invariant
 * `orderTotalMatchesItems` is already registered on OrderSchema in
 * `examples/docs/bookstore/index.ts`. This example demonstrates the invariant
 * in action: rejecting a tampered total with an invariant violation error,
 * then passing the canonical Bastian fixture (which satisfies all invariants).
 */

import {
  aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';

// The canonical fixture passes all invariants (structural + relational).
const validOrder = bookstoreEntities.validate(OrderSchema.$id, aboxFixtures.order);

console.assert(validOrder.ok);
console.assert(!validOrder.items.some((errItem) => {
  return errItem.keyword === 'jt:invariant';
}));

console.log('valid order result:', validOrder.ok ? 'ok' : 'invalid');
console.log('valid order error count:', validOrder.items.length);

// Now tamper with the total — claim €1000 when items sum to €850.
const tamperedOrder = {
  ...aboxFixtures.order,
  'orderTotal': {
    'amount': 1000,
    'currency': aboxFixtures.order.orderTotal.currency
  }
};

// validate() surfaces the orderTotalMatchesItems invariant failure.
const errs = bookstoreEntities.validate(OrderSchema.$id, tamperedOrder);

console.assert(!errs.ok);
console.assert(errs.items.some((errItem) => {
  return errItem.keyword === 'jt:invariant';
}));

const invariantErr = errs.items.find((errItem) => {
  return errItem.keyword === 'jt:invariant';
});

console.log('tampered order result:', errs.ok ? 'ok' : 'invalid');
console.log('invariant keyword:', invariantErr?.keyword);
console.log('invariant message:', invariantErr?.message);

// is() returns false for the tampered order.
console.assert(!bookstoreEntities.is(OrderSchema.$id, tamperedOrder));

console.log('is() on tampered order:', bookstoreEntities.is(OrderSchema.$id, tamperedOrder));
Output
Press Execute to run this example against the real library.

Example 2: Invariant failure surfaces in validate(), instantiate(), is()

/**
 * addInvariant — Example 2: Invariant failure surfaces in validate(), instantiate(), is()
 * Demonstrates: jt:invariant keyword in ValidationErrors, is() → false, instantiate() throws
 *
 * The canonical bookstore registry already registers the `orderTotalMatchesItems`
 * invariant on OrderSchema. A tampered order — total claims €1000 when items
 * sum to €850 — triggers the invariant through all three validation surfaces.
 */

import {
  InstantiationError
} from '../../../src/index.js';
import {
  aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';

const badOrder = {
  ...aboxFixtures.order,
  'orderTotal': {
    // wrong — items sum to 850
    'amount': 1000,
    'currency': 'EUR'
  }
};

// validate() — invariant failure as ValidationErrorType with keyword: 'jt:invariant'
const errs = bookstoreEntities.validate(OrderSchema.$id, badOrder);

console.assert(!errs.ok);
console.assert(errs.items.some((errItem) => {
  return errItem.keyword === 'jt:invariant';
}));
console.assert(errs.items.some((errItem) => {
  return errItem.message.includes('does not equal');
}));

// is() — returns false when invariant fails
console.assert(!bookstoreEntities.is(OrderSchema.$id, badOrder));

// instantiate() — throws InstantiationError carrying the same ValidationErrors
let threw = false;

try {
  bookstoreEntities.instantiate(OrderSchema.$id, badOrder);
} catch (error) {
  threw = error instanceof InstantiationError;
}

console.assert(threw);

const invariantErrors = errs.items.filter((errItem) => {
  return errItem.keyword === 'jt:invariant';
});

console.log('validate() ok:', errs.ok);
console.log('invariant errors:', invariantErrors.map((errItem) => {
  return errItem.message;
}));
console.log('is() result:', bookstoreEntities.is(OrderSchema.$id, badOrder));
console.log('instantiate() threw InstantiationError:', threw);
Output
Press Execute to run this example against the real library.

Example 3: Imperative add after construction

/**
 * addInvariant — Example 3: Imperative add after construction (review body length)
 * Demonstrates: addInvariant<T> with pointer, runtime add to existing registry
 *
 * A 5-star review of Michael Ende's Die unendliche Geschichte (1979 Thienemann
 * first edition) must have a body of at least 50 characters. The invariant is
 * registered imperatively against the already-constructed bookstore registry.
 */

import type { Review } from '../bookstore/index.js';
import {
  aboxFixtures, bookstoreEntities, ReviewSchema
} from '../bookstore/index.js';

bookstoreEntities.addInvariant<Review>(ReviewSchema.$id, {
  'fn': (review) => {
    if (review.rating === 5 && review.body.length < 50) {
      return '5-star reviews must have a body of at least 50 characters';
    }

    return null;
  },
  'name': 'highRatingRequiresDetailedReview',
  'pointer': '/body'
});

// The canonical review fixture has rating 5 and a sufficiently long body — passes.
const validResult = bookstoreEntities.validate(ReviewSchema.$id, aboxFixtures.review);

console.assert(validResult.ok);

// A terse 5-star review triggers the invariant.
const shortReview = {
  ...aboxFixtures.review,
  'body': 'Great read, loved it.',
  'rating': 5
};

const failResult = bookstoreEntities.validate(ReviewSchema.$id, shortReview);

console.assert(!failResult.ok);
console.assert(failResult.items.some((errItem) => {
  return errItem.keyword === 'jt:invariant';
}));

console.log('valid review passes:', validResult.ok);
console.log('short 5-star review passes:', failResult.ok);
console.log('invariant error:', failResult.items.find((errItem) => {
  return errItem.keyword === 'jt:invariant';
})?.message);

// Cleanup.
bookstoreEntities.removeInvariant(ReviewSchema.$id, 'highRatingRequiresDetailedReview');
Output
Press Execute to run this example against the real library.

Behaviour table

MethodInvariant behaviour
validate()Returns invariant failures as ValidationErrorType items in the ValidationErrors collection with keyword: 'jt:invariant'
instantiate()Throws InstantiationError when any invariant fails (the error carries ValidationErrors on .errors)
is()Returns false when any invariant fails
aggregate() / report()Both ValidationErrors views include invariant errors alongside structural errors

Invariants do not run when structural validation already failed - this prevents noise from cascading errors.

Bad examples - what NOT to do

Anti-pattern 1: Using an invariant for a constraint that JSON Schema can express

/**
 * addInvariant — Example 3: Imperative add after construction (review body length)
 * Demonstrates: addInvariant<T> with pointer, runtime add to existing registry
 *
 * A 5-star review of Michael Ende's Die unendliche Geschichte (1979 Thienemann
 * first edition) must have a body of at least 50 characters. The invariant is
 * registered imperatively against the already-constructed bookstore registry.
 */

import type { Review } from '../bookstore/index.js';
import {
  aboxFixtures, bookstoreEntities, ReviewSchema
} from '../bookstore/index.js';

bookstoreEntities.addInvariant<Review>(ReviewSchema.$id, {
  'fn': (review) => {
    if (review.rating === 5 && review.body.length < 50) {
      return '5-star reviews must have a body of at least 50 characters';
    }

    return null;
  },
  'name': 'highRatingRequiresDetailedReview',
  'pointer': '/body'
});

// The canonical review fixture has rating 5 and a sufficiently long body — passes.
const validResult = bookstoreEntities.validate(ReviewSchema.$id, aboxFixtures.review);

console.assert(validResult.ok);

// A terse 5-star review triggers the invariant.
const shortReview = {
  ...aboxFixtures.review,
  'body': 'Great read, loved it.',
  'rating': 5
};

const failResult = bookstoreEntities.validate(ReviewSchema.$id, shortReview);

console.assert(!failResult.ok);
console.assert(failResult.items.some((errItem) => {
  return errItem.keyword === 'jt:invariant';
}));

console.log('valid review passes:', validResult.ok);
console.log('short 5-star review passes:', failResult.ok);
console.log('invariant error:', failResult.items.find((errItem) => {
  return errItem.keyword === 'jt:invariant';
})?.message);

// Cleanup.
bookstoreEntities.removeInvariant(ReviewSchema.$id, 'highRatingRequiresDetailedReview');
Output
Press Execute to run this example against the real library.

Comparison

ts
jt.addInvariant<Order>('https://bookstore.example/Order', {
  name:    'totalMatchesItems',
  pointer: '/orderTotal',
  fn: (order) => {
    const computed = order.orderLines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
    return Math.abs(order.orderTotal - computed) < 0.01 ? null : 'total mismatch';
  },
});
ts
// Zod uses .superRefine() or .refine() for cross-field validation:
const OrderSchema = baseOrderSchema.refine(
  (order) => Math.abs(order.total - order.items.reduce((s, l) => s + l.unit_price * l.quantity, 0)) < 0.01,
  { message: 'total mismatch', path: ['total'] }
);
ts
import * as v from 'valibot';
const OrderSchema = v.pipe(
  baseOrderSchema,
  v.check(
    (order) => Math.abs(order.total - order.items.reduce((s, l) => s + l.unitPrice * l.quantity, 0)) < 0.01,
    'total mismatch',
  ),
);
// Limitation: Valibot has no registry, so cross-field rules cannot be
// added or removed against a registered schema by name; rebuild the schema.
ts
import * as t from 'io-ts';
// io-ts uses t.refinement to attach a predicate to a codec:
const OrderCodec = t.refinement(
  baseOrderCodec,
  (order) => Math.abs(order.total - order.items.reduce(
    (sum, line) => sum + line.unitPrice * line.quantity, 0,
  )) < 0.01,
  'OrderTotalMatchesItems',
);
// Limitation: io-ts has no registry, so cross-field rules cannot be added
// or removed against a registered schema by name; rebuild the codec.
// Refinement messages are limited to the codec name, not a field-pinned message.
ts
// Not a first-class concept  - apply manually after Type validation:
if (!Check(OrderSchema, data)) throw new Error('invalid structure');
const computed = data.items.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
if (Math.abs(data.total - computed) >= 0.01) throw new Error('total mismatch');
ts
// Not built in  - manual cross-field check after ajv.validate().
py
from pydantic import model_validator

class Order(BaseModel):
    items: list[OrderLine]
    total: float

    @model_validator(mode='after')
    def total_matches_items(self) -> 'Order':
        computed = sum(l.unit_price * l.quantity for l in self.items)
        if abs(self.total - computed) >= 0.01:
            raise ValueError(f'total must equal sum of items')
        return self
ts
// Limitation: feature not directly supported in Yup. See /comparisons for the matrix.
ts
// Limitation: feature not directly supported in Joi. See /comparisons for the matrix.
ts
// Limitation: feature not directly supported in Effect Schema. See /comparisons for the matrix.
ts
// Limitation: feature not directly supported in ArkType. See /comparisons for the matrix.
ts
// Limitation: feature not directly supported in Runtypes. See /comparisons for the matrix.

JsonTology.removeInvariant

Declaration. Removes the invariant with the given name from the schema identified by schemaId. After removal, subsequent calls to instantiate(), validate(), and is() will not run that invariant.

Use this when business rules change at runtime - promotional periods relaxing constraints, feature flags switching validation levels, or A/B testing different rule sets.

Examples

Example 1: Remove a review length requirement during a promotion

/**
 * removeInvariant — Example 1: Remove a review length requirement during a promotion
 * Demonstrates: addInvariant + removeInvariant for runtime rule toggling
 *
 * During a promotional event the minimum body length for 5-star reviews is
 * temporarily relaxed. The canonical Bastian Balthazar Bux review of the
 * 1979 Thienemann Neverending Story first edition is used as the fixture.
 */

import type { Review } from '../bookstore/index.js';
import {
  aboxFixtures, bookstoreEntities, ReviewSchema
} from '../bookstore/index.js';

const INVARIANT_NAME = 'promotionalBodyLength';

// Register the invariant (minimum 50 chars for 5-star reviews).
bookstoreEntities.addInvariant<Review>(ReviewSchema.$id, {
  'fn': (review) => {
    if (review.rating === 5 && review.body.length < 50) {
      return '5-star reviews must have a body of at least 50 characters';
    }

    return null;
  },
  'name': INVARIANT_NAME,
  'pointer': '/body'
});

const shortReview = {
  ...aboxFixtures.review,
  'body': 'Magnificent.',
  'rating': 5
};

// Fails while invariant is active.
console.assert(!bookstoreEntities.is(ReviewSchema.$id, shortReview));

console.log('before removal - short review valid:', bookstoreEntities.is(ReviewSchema.$id, shortReview));

// Remove during promotional event (relax minimum body length).
bookstoreEntities.removeInvariant(ReviewSchema.$id, INVARIANT_NAME);

// Passes after removal — short 5-star review is now acceptable.
console.assert(bookstoreEntities.is(ReviewSchema.$id, shortReview));

console.log('after removal - short review valid:', bookstoreEntities.is(ReviewSchema.$id, shortReview));
Output
Press Execute to run this example against the real library.

See also

Released under the MIT License.