Skip to content

Constraint Brands: Keywords

Siblings: Structural Narrowing  |  Validation modes reference

json-tology surfaces JSON Schema constraint keywords as compile-time phantom brands. Two values that satisfy different constraints produce incompatible TypeScript types, preventing silent misuse at compile time.

What changes

Without brands, { type: 'string', format: 'email' } and { type: 'string', format: 'uri' } both infer as string. Any string can flow between them silently. With brands enabled (the default), each constraint keyword intersects a phantom brand onto the base type. The types become structurally incompatible.

/**
 * Constraint Brands: Keywords
 *
 * Demonstrates jt:* keyword-driven brands on canonical RareBookSchema
 * and SignedFirstEditionSchema: inverseFunctional on Customer.id,
 * transitive/irreflexive on Order.placedAt, and invariants on Order
 * and SignedFirstEdition.
 */

import type {
  InferType, SchemaReferencesMapType
} from '../../../src/types/index.js';
import {
  bookstoreEntities, CustomerSchema, SignedFirstEditionSchema
} from '../bookstore/index.js';
import type {
  bookstoreSchemas, OrderSchema
} from '../bookstore/index.js';

type BookstoreRefs = SchemaReferencesMapType<typeof bookstoreSchemas>;

type AssertEqualType<TLeft, TRight>
  = [TLeft] extends [TRight] ? [TRight] extends [TLeft] ? true : false : false;

function assert<T extends true>(_proof?: T): void {
  return;
}

// CustomerSchema.id carries jt:inverseFunctional brand — each customer ID
// maps to exactly one customer individual (a key constraint).
// BookstoreRefs resolves $ref fields to their named datatype types.
type Customer = InferType<typeof CustomerSchema, BookstoreRefs>;
type CustomerId = Customer extends { readonly 'id': infer I } ? I : never;
// CustomerId: string & InverseFunctionalBrandType
// (compile-time phantom brand, validated at instantiation)

assert<AssertEqualType<CustomerId extends string ? true : false, true>>();

// OrderSchema.placedAt carries temporal characteristics (transitive ordering
// of event placement). The schema registers an invariant ensuring consistent
// ordering across multiple orders.
type Order = InferType<typeof OrderSchema, BookstoreRefs>;
type PlacedAt = Order extends { readonly 'placedAt': infer P } ? P : never;
// PlacedAt: string (ISO 8601)
// At registration time, the Order schema includes jt:invariant rules
// that validate temporal consistency across instances

assert<AssertEqualType<PlacedAt extends string ? true : false, true>>();

// SignedFirstEditionSchema extends RareBookSchema with an invariant:
// signedFirstEditionIsSoloAuthored — only single-author books can be
// signed first editions. This is a cross-field rule that fires alongside
// structural validation.
type SignedFirstEdition = InferType<typeof SignedFirstEditionSchema, BookstoreRefs>;
type SignedAuthors = SignedFirstEdition extends
{ readonly 'authors': infer A }
  ? A
  : never;
// SignedAuthors: a tuple constrained to at most one element (maxCardinality restriction)
// The jt:invariant constraint on SignedFirstEdition enforces that
// authors.length === 1 at validation time.
// The restriction narrows authors to a bounded tuple — it extends readonly string[].

assert<AssertEqualType<SignedAuthors extends readonly string[] ? true : false, true>>();

// All three schemas are registered in bookstoreEntities with their
// jt:* keyword constraints active. At instantiation time, data is validated
// against these invariants and the narrowed types are enforced.

// Runtime demonstration: instantiate a Customer with a UUID id (inverseFunctional key).
const customer = bookstoreEntities.instantiate(CustomerSchema, {
  'customerId': '550e8400-e29b-41d4-a716-446655440000',
  'email': 'bastian@bookstore.example',
  'name': 'Bastian Balthazar Bux'
});

console.log('Customer customerId (inverseFunctional brand):', customer.customerId);
console.log('Customer email (format brand):', customer.email);

// SignedFirstEdition authors are constrained to a single-element tuple
// by jt:invariant (signedFirstEditionIsSoloAuthored). The schema $id
// confirms the registered constraint is active in the registry.
console.log('SignedFirstEdition $id:', SignedFirstEditionSchema.$id);
console.log('SignedFirstEdition is registered:', bookstoreEntities.registry.has(SignedFirstEditionSchema.$id));
Output
Press Execute to run this example against the real library.
Brands ON (default)Brands OFF
Email resolves tostring & FormatBrandInterface<'email'>string
Uri resolves tostring & FormatBrandInterface<'uri'>string
const x: Email = '' as stringcompile errorcompiles
const x: Email = '' as Uricompile errorcompiles
const x: Email = jt.instantiate(id, data)compilescompiles

The only way to obtain a branded value is through the validation API (instantiate, materialize, is, value.cast, etc.). This is intentional - it enforces that data passes runtime validation before being treated as a constrained type.

Branded keywords

String constraints Compile-time + Runtime

KeywordBrandConfig flagExample
formatFormatBrandInterface<F>formatBrandsformat: 'email' brands as FormatBrandInterface<'email'>
patternPatternBrandInterface<P>stringBrandspattern: '^[A-Z]' brands as PatternBrandInterface<'^[A-Z]'>
minLengthMinLengthBrandInterface<N>stringBrandsminLength: 5 brands as MinLengthBrandInterface<5>
maxLengthMaxLengthBrandInterface<N>stringBrandsmaxLength: 100 brands as MaxLengthBrandInterface<100>
contentMediaTypeContentMediaTypeBrandInterface<T>contentBrandscontentMediaType: 'image/png' brands as ContentMediaTypeBrandInterface<'image/png'>
contentEncodingContentEncodingBrandInterface<T>contentBrandscontentEncoding: 'base64' brands as ContentEncodingBrandInterface<'base64'>
import type { InferType } from '../../../src/types/index.js';
import { JsonTology } from '../../../src/index.js';

const PasswordSchema = {
  '$id': 'urn:brands:Password',
  'maxLength': 128,
  'minLength': 8,
  'pattern': '^(?=.*[A-Z])(?=.*[0-9])',
  'type': 'string'
} as const;

type Password = InferType<typeof PasswordSchema>;
// string & MinLengthBrandType<8> & MaxLengthBrandType<128> & PatternBrandType<'^(?=.*[A-Z])(?=.*[0-9])'>

const jt = JsonTology.create({
  'baseIri': 'urn:brands:',
  'enableStrictGraph': false,
  'schemas': [PasswordSchema]
});

// Valid password: meets minLength 8, has uppercase and digit.
const pw: Password = jt.instantiate(PasswordSchema.$id, 'Secret42!');

console.log('Branded password value:', pw);
console.log('Branded password is string:', typeof pw === 'string');

// Validation rejects a plain string that fails the pattern.
const errors = JsonTology.validate(PasswordSchema, 'weak');

console.log('Validation errors for "weak":', errors.items.map((err) => {
  return err.message;
}));
Output
Press Execute to run this example against the real library.

Number constraints Compile-time + Runtime

KeywordBrandConfig flagExample
formatFormatBrandInterface<F>formatBrandsformat: 'int32' brands as FormatBrandInterface<'int32'>
minimumMinimumBrandInterface<N>numericBrandsminimum: 0 brands as MinimumBrandInterface<0>
maximumMaximumBrandInterface<N>numericBrandsmaximum: 100 brands as MaximumBrandInterface<100>
exclusiveMinimumExclusiveMinimumBrandInterface<N>numericBrandsexclusiveMinimum: 0 brands as ExclusiveMinimumBrandInterface<0>
exclusiveMaximumExclusiveMaximumBrandInterface<N>numericBrandsexclusiveMaximum: 100 brands as ExclusiveMaximumBrandInterface<100>
multipleOfMultipleOfBrandInterface<N>numericBrandsmultipleOf: 5 brands as MultipleOfBrandInterface<5>
import type { InferType } from '../../../src/types/index.js';
import { JsonTology } from '../../../src/index.js';

const PercentSchema = {
  '$id': 'urn:brands:Percent',
  'maximum': 100,
  'minimum': 0,
  'type': 'number'
} as const;

const TemperatureSchema = {
  '$id': 'urn:brands:Temperature',
  'minimum': -273,
  'type': 'number'
} as const;

type Percent = InferType<typeof PercentSchema>;
type Temperature = InferType<typeof TemperatureSchema>;

// Percent:     number & MinimumBrandType<0> & MaximumBrandType<100>
// Temperature: number & MinimumBrandType<-273>

// Each branded value is constructed only from its own schema's brand —
// the two are incompatible (different MinimumBrand values, and Percent
// additionally carries MaximumBrand<100>), so neither is assignable to the
// other.
const jt = JsonTology.create({
  'baseIri': 'urn:brands:',
  'enableStrictGraph': false,
  'schemas': [
    PercentSchema,
    TemperatureSchema
  ]
});

const _percent: Percent = jt.instantiate(PercentSchema.$id, 0);
const _temp: Temperature = jt.instantiate(TemperatureSchema.$id, 0);

// Demonstrate incompatibility at the type level: Percent does not extend
// Temperature and vice versa.
type PercentIsNotTemperature = Percent extends Temperature ? false : true;
type TemperatureIsNotPercent = Temperature extends Percent ? false : true;
const _check: [PercentIsNotTemperature, TemperatureIsNotPercent] = [
  true,
  true
];

// Both are numbers at runtime but carry incompatible brands at compile time.
console.log('Percent value:', _percent, '(brand: minimum 0, maximum 100)');
console.log('Temperature value:', _temp, '(brand: minimum -273)');
// Percent does not extend Temperature — the brands are structurally distinct.
console.log('Brand incompatibility check [true, true]:', _check);
Output
Press Execute to run this example against the real library.

Array constraints Compile-time + Runtime

KeywordBrandConfig flagExample
uniqueItemsUniqueItemsBrandInterface / UniqueArrayBrandInterface<T>arrayBrandsuniqueItems: true brands the array
containsContainsBrandInterface<T>arrayBrandscontains: { type: 'number' } brands as ContainsBrandInterface<number>
minItemsMinItemsBrandInterface<N>arrayBrandsminItems: 1 brands as MinItemsBrandInterface<1>
maxItemsMaxItemsBrandInterface<N>arrayBrandsmaxItems: 10 brands as MaxItemsBrandInterface<10>

When contains is present without items, the array element type narrows to the contains schema type.

import type { InferType } from '../../../src/types/index.js';
import { JsonTology } from '../../../src/index.js';

const TagSetSchema = {
  '$id': 'urn:brands:TagSet',
  'items': { 'type': 'string' },
  'type': 'array',
  'uniqueItems': true
} as const;

type TagSet = InferType<typeof TagSetSchema>;
// readonly string[] & UniqueItemsBrandType

const NumberArraySchema = {
  '$id': 'urn:brands:NumberArray',
  'contains': { 'type': 'number' },
  'type': 'array'
} as const;

type NumberArray = InferType<typeof NumberArraySchema>;
// readonly number[] & ContainsBrandType<number>

const jt = JsonTology.create({
  'baseIri': 'urn:brands:',
  'enableStrictGraph': false,
  'schemas': [
    TagSetSchema,
    NumberArraySchema
  ]
});

const tags: TagSet = jt.instantiate(TagSetSchema.$id, [
  'fiction',
  'fantasy',
  'classic'
]);
const nums: NumberArray = jt.instantiate(NumberArraySchema.$id, [
  1,
  2,
  3
]);

console.log('TagSet (uniqueItems brand):', tags);
console.log('NumberArray (contains<number> brand):', nums);

// Duplicate tags are rejected at runtime by uniqueItems validation.
const dupErrors = JsonTology.validate(TagSetSchema, [
  'fiction',
  'fiction'
]);

console.log('Duplicate-tag errors:', dupErrors.items.map((err) => {
  return err.message;
}));
Output
Press Execute to run this example against the real library.

Object constraints Compile-time + Runtime

KeywordBrandConfig flagExample
minPropertiesMinPropertiesBrandInterface<N>objectBrandsminProperties: 1 brands as MinPropertiesBrandInterface<1>
maxPropertiesMaxPropertiesBrandInterface<N>objectBrandsmaxProperties: 5 brands as MaxPropertiesBrandInterface<5>

When additionalProperties: false and properties are declared, excess property keys are flagged as never at compile time (requires objectBrands enabled):

import type { InferType } from '../../../src/types/index.js';

const _ClosedSchema = {
  'additionalProperties': false,
  'properties': {
    'age': { 'type': 'integer' },
    'name': { 'type': 'string' }
  },
  'type': 'object'
} as const;

type Closed = InferType<typeof _ClosedSchema>;

// compiles — only the declared properties are present.
const ok: Closed = {
  'age': 30,
  'name': 'Bastian Balthazar Bux'
};

// additionalProperties: false → excess keys are rejected. `extra` is not among
// Closed's keys, so `{ extra: true, ... }` is a compile error on assignment to
// Closed. Asserted at the type level — no cast, no forced value:
type ExtraIsRejected = 'extra' extends keyof Closed ? false : true;
const extraIsRejected: ExtraIsRejected = true;

console.log('Valid closed object:', ok);
console.log('Closed type permits only:', Object.keys(ok).join(', '));
console.log('Excess key "extra" rejected at compile time:', extraIsRejected);
Output
Press Execute to run this example against the real library.

Nominal constraints Compile-time

KeywordBrandConfig flagExample
$idSchemaIdBrandInterface<TId>nominalBrands$id: 'https://example.com/User' makes types nominally distinct
$schemaDialectBrandInterface<T>nominalBrands$schema: 'https://json-schema.org/draft/2020-12/schema' brands the dialect

Nominal brands make structurally identical schemas produce incompatible types when they have different $id values. Use NominalSchemaType<T> to access the branded type:

import type { NominalSchemaType } from '../../../src/types/index.js';

const _UserSchema = {
  '$id': 'https://example.com/User',
  'properties': { 'name': { 'type': 'string' } },
  'type': 'object'
} as const;

const _EmployeeSchema = {
  '$id': 'https://example.com/Employee',
  'properties': { 'name': { 'type': 'string' } },
  'type': 'object'
} as const;

type User = NominalSchemaType<typeof _UserSchema>;
type Employee = NominalSchemaType<typeof _EmployeeSchema>;

// Structurally identical but nominally distinct  - cannot assign one to the other
// The $id brands make User and Employee incompatible at compile time.
type UserIsNotEmployee = User extends Employee ? false : true;
type EmployeeIsNotUser = Employee extends User ? false : true;
const brandCheck: [UserIsNotEmployee, EmployeeIsNotUser] = [
  true,
  true
];

console.log('User $id:', _UserSchema.$id);
console.log('Employee $id:', _EmployeeSchema.$id);
console.log('Nominal incompatibility (User !extends Employee, Employee !extends User):', brandCheck);
Output
Press Execute to run this example against the real library.

Named format brands Compile-time

25 named format-brand aliases cover the full JSON Schema 2020-12 standard format set plus json-tology built-ins. Each alias specialises FormatBrandInterface<F> to a single format string so function signatures can name the required format explicitly.

The brand-first intersection ordering (FormatBrandInterface<F> & string) keeps the named brand visible in IDE hovers instead of being hidden behind string.

import type {
  EmailBrandType, UuidBrandType
} from '../../../src/types/index.js';
import { JsonTology } from '../../../src/index.js';

const EmailSchema = {
  '$id': 'urn:brands:Email',
  'format': 'email',
  'type': 'string'
} as const;
const UuidSchema = {
  '$id': 'urn:brands:Uuid',
  'format': 'uuid',
  'type': 'string'
} as const;

const jt = JsonTology.create({
  'baseIri': 'urn:brands:',
  'enableStrictGraph': false,
  'schemas': [
    EmailSchema,
    UuidSchema
  ]
});

// Only values produced by the validation API carry the named format brand.
const email: EmailBrandType = jt.instantiate(EmailSchema.$id, 'bastian@bookstore.example');
const uuid: UuidBrandType = jt.instantiate(UuidSchema.$id, '550e8400-e29b-41d4-a716-446655440000');

console.log('EmailBrandType value:', email);
console.log('UuidBrandType value:', uuid);
Output
Press Execute to run this example against the real library.

Standard format aliases

Brand typeFormat string
EmailBrandInterface'email'
IdnEmailBrandInterface'idn-email'
UriBrandInterface'uri'
UriReferenceBrandInterface'uri-reference'
UriTemplateBrandInterface'uri-template'
IriBrandInterface'iri'
IriReferenceBrandInterface'iri-reference'
UuidBrandInterface'uuid'
DateBrandInterface'date'
DateTimeBrandInterface'date-time'
TimeBrandInterface'time'
DurationBrandInterface'duration'
HostnameBrandInterface'hostname'
IdnHostnameBrandInterface'idn-hostname'
Ipv4BrandInterface'ipv4'
Ipv6BrandInterface'ipv6'
RegexBrandInterface'regex'
JsonPointerBrandInterface'json-pointer'
RelativeJsonPointerBrandInterface'relative-json-pointer'
BinaryBrandInterface'binary'
ByteBrandInterface'byte'
Int32BrandInterface'int32'
Int64BrandInterface'int64'
FloatBrandInterface'float'
DoubleBrandInterface'double'

Use the generic FormatBrandInterface<F> for custom format strings not covered by the named aliases.

Composition

Brands compose naturally through JSON Schema composition keywords.

allOf Compile-time + Runtime

Intersection merges brands from all branches:

import type { InferType } from '../../../src/types/index.js';
import { JsonTology } from '../../../src/index.js';

const ValidatedEmailSchema = {
  '$id': 'urn:brands:ValidatedEmail',
  'allOf': [
    {
      'format': 'email',
      'type': 'string'
    },
    {
      'minLength': 5,
      'type': 'string'
    }
  ]
} as const;

type VEmail = InferType<typeof ValidatedEmailSchema>;
// string & FormatBrandType<'email'> & string & MinLengthBrandType<5>
// simplifies to: string & FormatBrandType<'email'> & MinLengthBrandType<5>

const jt = JsonTology.create({
  'baseIri': 'urn:brands:',
  'enableStrictGraph': false,
  'schemas': [ValidatedEmailSchema]
});

// allOf intersects brands from both branches: email format AND minLength 5.
const vemail: VEmail = jt.instantiate(ValidatedEmailSchema.$id, 'user@example.com');

console.log('allOf branded email:', vemail);

// Validation rejects strings that fail either branch.
const shortErrors = JsonTology.validate(ValidatedEmailSchema, 'a@b');

console.log('Errors for "a@b" (too short):', shortErrors.items.map((err) => {
  return err.message;
}));
Output
Press Execute to run this example against the real library.

anyOf / oneOf Compile-time + Runtime

Union preserves each branch's brands independently:

import type { InferType } from '../../../src/types/index.js';
import { JsonTology } from '../../../src/index.js';

const IdSchema = {
  '$id': 'urn:brands:Id',
  'oneOf': [
    {
      'format': 'uuid',
      'type': 'string'
    },
    {
      'minimum': 1,
      'type': 'number'
    }
  ]
} as const;

type Id = InferType<typeof IdSchema>;
// (string & FormatBrandType<'uuid'>) | (number & MinimumBrandType<1>)

const jt = JsonTology.create({
  'baseIri': 'urn:brands:',
  'enableStrictGraph': false,
  'schemas': [IdSchema]
});

// oneOf preserves each branch's brand independently as a union.
const stringId: Id = jt.instantiate(IdSchema.$id, '550e8400-e29b-41d4-a716-446655440000');
const numberId: Id = jt.instantiate(IdSchema.$id, 42);

console.log('String branch (uuid brand):', stringId);
console.log('Number branch (minimum 1 brand):', numberId);
Output
Press Execute to run this example against the real library.

Utility types

DeprecatedKeysType<T> / NonDeprecatedSchemaType<T>

Filter deprecated properties from a schema type:

import type {
  DeprecatedKeysType, NonDeprecatedSchemaType
} from '../../../src/types/index.js';

const _UserSchema = {
  'properties': {
    'legacyId': {
      'deprecated': true,
      'type': 'string'
    },
    'name': { 'type': 'string' }
  },
  'required': ['name'],
  'type': 'object'
} as const;

// 'legacyId'
type DepKeys = DeprecatedKeysType<typeof _UserSchema>;
// { name: string }  - no legacyId
type User = NonDeprecatedSchemaType<typeof _UserSchema>;

// DepKeys is a union of deprecated property names. User omits them.
type DepKeysIsLegacyId = DepKeys extends 'legacyId' ? true : false;
type UserHasNoLegacyId = User extends { 'legacyId': unknown } ? false : true;

const check: [DepKeysIsLegacyId, UserHasNoLegacyId] = [
  true,
  true
];

console.log('Deprecated key is "legacyId":', check[0]);
console.log('NonDeprecated type omits legacyId:', check[1]);
Output
Press Execute to run this example against the real library.

EnumValuesType<T> / ExhaustiveType<T>

Extract enum values and enforce exhaustive handling:

/**
 * EnumValuesType / ExhaustiveType — extract enum values and enforce
 * exhaustive handling at compile time.
 *
 * Uses the canonical `PrintStatusSchema` from the bookstore, whose
 * enum literally is `'inPrint' | 'outOfPrint'`. The switch must cover
 * every literal — `ExhaustiveType<typeof s>` resolves to `never` only
 * if no enum members remain, so a missing case surfaces a compile
 * error.
 */

import type {
  EnumValuesType, ExhaustiveType
} from '../../../src/types/index.js';
import type { PrintStatusSchema } from '../bookstore/index.js';

type PrintStatus = EnumValuesType<typeof PrintStatusSchema>;

function describe(status: PrintStatus): string {
  switch (status) {
    case 'inPrint':
      return 'currently printed';
    case 'limitedRun':
      return 'limited run';
    case 'outOfPrint':
      return 'out of print';
    default:
      return status satisfies ExhaustiveType<typeof status>;
  }
}

console.log('inPrint:', describe('inPrint'));
console.log('outOfPrint:', describe('outOfPrint'));
console.log('limitedRun:', describe('limitedRun'));
Output
Press Execute to run this example against the real library.

DefaultAlignedType<T>

Validates that default values match the declared type. Resolves to never when a default mismatches:

/**
 * DefaultAlignedType — validates that `default` values match the
 * declared type. Resolves to `never` when a default mismatches.
 *
 * The "Good" schema declares a number default for a number property;
 * the "Bad" schema declares a string default for a number property.
 * The bad-shape type resolves to `never`, which is how the type
 * system surfaces the misalignment without a runtime check.
 */

import type { DefaultAlignedType } from '../../../src/types/index.js';

const _GoodCountSchema = {
  'properties': {
    'count': {
      'default': 0,
      'type': 'number'
    }
  },
  'type': 'object'
} as const;

const _BadCountSchema = {
  'properties': {
    'count': {
      'default': 'zero',
      'type': 'number'
    }
  },
  'type': 'object'
} as const;

type Good = DefaultAlignedType<typeof _GoodCountSchema>;
type Bad = DefaultAlignedType<typeof _BadCountSchema>;

// Good keeps the schema type; Bad collapses to never.
// The check is entirely compile-time: Good === typeof _GoodCountSchema,
// Bad === never (the string default mismatches the number property type).
type GoodIsSchema = Good extends typeof _GoodCountSchema ? true : false;
type BadIsNever = [Bad] extends [never] ? true : false;

const goodIsSchema: GoodIsSchema = true;
const badIsNever: BadIsNever = true;

console.log('Good matches schema shape (default 0 aligns with number type):', goodIsSchema);
console.log('Bad collapses to never (default "zero" mismatches number type):', badIsNever);
Output
Press Execute to run this example against the real library.

IntegerRangeType<Min, Max> / MultipleOfRangeType<Min, Max, Step>

Manual utilities for generating literal union types from integer ranges:

/**
 * IntegerRangeType / MultipleOfRangeType — manual utilities for
 * generating literal union types from integer ranges.
 *
 * Practical for ranges in 0..50. Larger ranges fall back to `number`.
 * The bookstore's canonical `RatingScoreSchema` covers `1..5`, so the
 * rating literal union is the natural test case.
 */

import type {
  IntegerRangeType, MultipleOfRangeType
} from '../../../src/types/index.js';

type Rating = IntegerRangeType<1, 5>;
type EvenDigit = MultipleOfRangeType<0, 8, 2>;

// Both are literal union types at compile time.
// Rating: 1 | 2 | 3 | 4 | 5
// EvenDigit: 0 | 2 | 4 | 6 | 8
const rating: Rating = 4;
const evenDigit: EvenDigit = 6;

console.log('Rating (1 | 2 | 3 | 4 | 5):', rating);
console.log('EvenDigit (0 | 2 | 4 | 6 | 8):', evenDigit);
Output
Press Execute to run this example against the real library.

Practical for ranges in 0-50. Larger ranges fall back to number.

Configuration

All brands are enabled by default. To disable specific categories, create a .d.ts file anywhere in your project's include path (e.g. at the project root or in a types/ directory).

How it works

json-tology exports a JsonTologyTypeConfigInterface with all flags set to true. TypeScript's module augmentation lets you re-open that interface and override specific flags. The compiler merges your declaration with the original, and the type system reads the merged result.

This is the same pattern used by libraries like Zod, tRPC, Express, and Fastify for extensible type configuration.

Setup

Create a file (any name, .d.ts extension) in your project:

ts
// json-tology.d.ts
declare module 'json-tology/types' {
  interface JsonTologyTypeConfigInterface {
    formatBrands: false;   // disable format brands
    numericBrands: false;  // disable numeric brands
  }
}

No import needed. No build step. The file just needs to be in your tsconfig's include path.

Available flags

FlagDefaultControls
brandstrueMaster switch. When false, disables all brands.
formatBrandstrueformat on strings and numbers.
stringBrandstrueminLength, maxLength, pattern on strings.
numericBrandstrueminimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf on numbers.
arrayBrandstrueuniqueItems, contains, minItems, maxItems on arrays.
contentBrandstruecontentMediaType, contentEncoding on strings.
objectBrandstrueminProperties, maxProperties on objects. additionalProperties: false excess flagging.
nominalBrandstrue$id nominal identity, $schema dialect branding.

The master brands flag takes precedence. When brands: false, all other flags are ignored and no brands are applied.

Before and after: format brands

/**
 * Format brands — Email resolves to a branded string type.
 *
 * With `formatBrands: true` (the default), `InferType` on a
 * `format: 'email'` schema produces `string & FormatBrandType<'email'>`.
 * Plain strings cannot satisfy the branded type — values must come
 * through the validation API.
 */

import { JsonTology } from '../../../src/index.js';
import type { InferType } from '../../../src/types/index.js';

const EmailSchema = {
  '$id': 'urn:brands:Email',
  'format': 'email',
  'type': 'string'
} as const;

type Email = InferType<typeof EmailSchema>;
// Email: string & FormatBrandType<'email'>

const jt = JsonTology.create({
  'baseIri': 'urn:brands:',
  'enableStrictGraph': false,
  'schemas': [EmailSchema]
});

const email: Email = jt.instantiate(EmailSchema.$id, 'bastian@bookstore.example');

console.log('Email (format brand):', email);
console.log('Email is string:', typeof email === 'string');
Output
Press Execute to run this example against the real library.
formatBrandsEmail resolves toPlain string assignable?
true (default)string & FormatBrandInterface<'email'>No - compile error
falsestringYes

Before and after: numeric brands

/**
 * Numeric brands — `minimum` and `maximum` produce branded number types.
 *
 * With `numericBrands: true` (the default), a schema with `minimum: 0`
 * and `maximum: 100` resolves to
 * `number & MinimumBrandType<0> & MaximumBrandType<100>`.
 * A plain `number` is not assignable to `Score` without passing through
 * the validation API.
 */

import { JsonTology } from '../../../src/index.js';
import type { InferType } from '../../../src/types/index.js';

const ScoreSchema = {
  '$id': 'urn:brands:Score',
  'maximum': 100,
  'minimum': 0,
  'type': 'number'
} as const;

type Score = InferType<typeof ScoreSchema>;

const jt = JsonTology.create({
  'baseIri': 'urn:brands:',
  'enableStrictGraph': false,
  'schemas': [ScoreSchema]
});

const score: Score = jt.instantiate(ScoreSchema.$id, 75);

console.log('Score (min 0, max 100):', score);
console.log('Score is number:', typeof score === 'number');
Output
Press Execute to run this example against the real library.
numericBrandsScore resolves toPlain number assignable?
true (default)number & MinimumBrandInterface<0> & MaximumBrandInterface<100>No - compile error
falsenumberYes

Before and after: string brands

import type { InferType } from '../../../src/types/index.js';
import { JsonTology } from '../../../src/index.js';

const CodeSchema = {
  '$id': 'urn:brands:Code',
  'maxLength': 10,
  'minLength': 3,
  'type': 'string'
} as const;

type Code = InferType<typeof CodeSchema>;
// string & MinLengthBrandType<3> & MaxLengthBrandType<10>

const jt = JsonTology.create({
  'baseIri': 'urn:brands:',
  'enableStrictGraph': false,
  'schemas': [CodeSchema]
});

const code: Code = jt.instantiate(CodeSchema.$id, 'ABC');

console.log('Code value (minLength 3, maxLength 10):', code);

// Too-short strings are rejected.
const shortErrors = JsonTology.validate(CodeSchema, 'AB');

console.log('Errors for "AB" (too short):', shortErrors.items.map((err) => {
  return err.message;
}));
Output
Press Execute to run this example against the real library.
stringBrandsCode resolves toPlain string assignable?
true (default)string & MinLengthBrandInterface<3> & MaxLengthBrandInterface<10>No - compile error
falsestringYes

Before and after: array brands

/**
 * `uniqueItems` array brand — homogeneous arrays with `uniqueItems: true`
 * carry `UniqueItemsBrandType`.
 *
 * A plain `string[]` is not assignable to the branded type. Values must
 * pass through the validation API which enforces uniqueness at runtime.
 */

import { JsonTology } from '../../../src/index.js';
import type { InferType } from '../../../src/types/index.js';

const SetSchema = {
  '$id': 'urn:brands:StringSet',
  'items': { 'type': 'string' },
  'type': 'array',
  'uniqueItems': true
} as const;

type StringSet = InferType<typeof SetSchema>;

const jt = JsonTology.create({
  'baseIri': 'urn:brands:',
  'enableStrictGraph': false,
  'schemas': [SetSchema]
});

const tags: StringSet = jt.instantiate(SetSchema.$id, [
  'fiction',
  'fantasy',
  'classic'
]);

console.log('StringSet (uniqueItems brand):', tags);
console.log('StringSet is array:', Array.isArray(tags));
Output
Press Execute to run this example against the real library.
arrayBrandsSet resolves toreadonly string[] assignable?
true (default)readonly string[] & UniqueItemsBrandInterfaceNo - compile error
falsereadonly string[]Yes

Before and after: object brands

/**
 * Object brands — `additionalProperties: false` closed object.
 *
 * With `objectBrands: true` (the default), excess property keys are
 * flagged as `never` at compile time when the schema declares both
 * `properties` and `additionalProperties: false`.
 */

import type { InferType } from '../../../src/types/index.js';

const _ClosedSchema = {
  'additionalProperties': false,
  'properties': { 'name': { 'type': 'string' } },
  'type': 'object'
} as const;

type Closed = InferType<typeof _ClosedSchema>;

const valid: Closed = { 'name': 'Bastian Balthazar Bux' };

console.log('Closed object:', valid);
console.log('name property:', valid.name);
Output
Press Execute to run this example against the real library.
objectBrandsExcess property { name: 'x', extra: 1 }Plain object assignable?
true (default)compile error - extra is neverNo
falsecompiles (no excess check)Yes

Before and after: all brands off

ts
// json-tology.d.ts  - disable everything
declare module 'json-tology/types' {
  interface JsonTologyTypeConfigInterface {
    brands: false;
  }
}

All InferType results revert to plain TypeScript types with no phantom brands. The library behaves identically to before brands were introduced. Runtime validation is unaffected.

Checking your config

The augmented interface is type-checked. A typo in a flag name produces a compile error:

ts
declare module 'json-tology/types' {
  interface JsonTologyTypeConfigInterface {
    formattBrands: false;  // compile error  - property does not exist
  }
}

Obtaining branded values

Branded types enforce that data goes through validation. The validation API returns branded types automatically:

/**
 * Obtaining branded values — only validation produces them.
 *
 * Branded types enforce that data goes through validation. The
 * canonical EmailSchema in the bookstore carries
 * `FormatBrandType<'email'>`; the only way to obtain a value of
 * that branded type is to pass through `instantiate`, `value.instantiate`,
 * or the type guard `is`.
 */

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

const candidate = 'bastian.bux@bookstore.example';

const email = bookstoreEntities.instantiate(EmailSchema, candidate);
const echo = bookstoreEntities.value.instantiate(EmailSchema.$id, candidate) as string;

console.log('instantiate produces branded email:', email);
console.log('value.instantiate produces same value:', echo);

if (bookstoreEntities.is(EmailSchema, candidate)) {
  // candidate is narrowed to the branded email type inside this branch.
  console.log('is() guard narrows to branded type:', candidate);
}
Output
Press Execute to run this example against the real library.

See also

Released under the MIT License.