Constraint Brands: Structural Narrowing
Siblings: Branded Keywords | Validation modes reference
Beyond phantom brands on individual keywords, the type system narrows structural TypeScript types from JSON Schema constraints. These narrowings produce literal union types, template literal key types, and conditional discriminated unions - all at compile time.
Structural narrowing Compile-time
Auto integer ranges Compile-time
Bounded integer schemas with both bounds in the 0-50 range automatically produce literal union types:
/**
* Constraint Brands: Structural Narrowing
*
* Demonstrates compile-time narrowing of branded primitives from the
* canonical bookstore schemas: IsbnSchema, EmailSchema, and their
* inclusion in Book/Customer entities produces narrowed string types
* at the type level via format and pattern brands.
*/
import type {
InferType, SchemaReferencesMapType
} from '../../../src/types/index.js';
import {
bookstoreEntities, EmailSchema, IsbnSchema
} from '../bookstore/index.js';
import type {
BookSchema, bookstoreSchemas, CustomerSchema
} 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;
}
// IsbnSchema carries a pattern brand for ISBN-13: ^\\d{13}$
type Isbn = InferType<typeof IsbnSchema>;
// Resolves to: string & PatternBrandType<'^\\d{13}$'>
// EmailSchema carries a format brand for email
type Email = InferType<typeof EmailSchema>;
// Resolves to: string & FormatBrandType<'email'>
// When IsbnSchema is referenced within BookSchema via $ref,
// the inferred book.isbn property narrows to the branded type.
// BookstoreRefs resolves $ref fields to their named datatype types.
type Book = InferType<typeof BookSchema, BookstoreRefs>;
type BookIsbn = Book extends { readonly 'isbn': infer I } ? I : never;
// BookIsbn carries the same PatternBrandType<'^\\d{13}$'>
assert<AssertEqualType<BookIsbn extends string ? true : false, true>>();
// Similarly, CustomerSchema.$ref EmailSchema produces a branded email type
type Customer = InferType<typeof CustomerSchema, BookstoreRefs>;
type CustomerEmail = Customer extends { readonly 'email': infer E } ? E : never;
// CustomerEmail carries FormatBrandType<'email'>
assert<AssertEqualType<CustomerEmail extends string ? true : false, true>>();
// The three types are structurally incompatible at compile time
// because their brands differ
assert<AssertEqualType<Isbn extends Email ? false : true, true>>();
assert<AssertEqualType<Email extends Isbn ? false : true, true>>();
// Only values that pass bookstoreEntities.instantiate() validation
// receive the branded type at runtime. The bookstoreEntities registry
// validates data against these branded schemas.
const isbn = bookstoreEntities.instantiate(IsbnSchema, '9780525559474');
const email = bookstoreEntities.instantiate(EmailSchema, 'bastian@bookstore.example');
// Both are strings at runtime, but carry incompatible brands at compile time.
console.log('Isbn brand (pattern ^\\d{13}$):', isbn);
console.log('Email brand (format email):', email);
console.log('Isbn extends string:', typeof isbn === 'string');
console.log('Email extends string:', typeof email === 'string');
Exclusive bounds are normalized automatically: exclusiveMinimum: 0 becomes inclusive minimum 1, exclusiveMaximum: 6 becomes inclusive maximum 5.
multipleOf stepped ranges Compile-time
When multipleOf is present alongside bounds, only multiples within the range are included:
import type { InferType } from '../../../src/types/index.js';
import { JsonTology } from '../../../src/index.js';
const EvenDiceSchema = {
'$id': 'urn:brands:EvenDice',
'maximum': 6,
'minimum': 1,
'multipleOf': 2,
'type': 'integer'
} as const;
type EvenDice = InferType<typeof EvenDiceSchema>;
// 2 | 4 | 6
const jt = JsonTology.create({
'baseIri': 'urn:brands:',
'enableStrictGraph': false,
'schemas': [EvenDiceSchema]
});
// Only multiples of 2 within 1..6 are valid: 2, 4, 6.
const roll: EvenDice = jt.instantiate(EvenDiceSchema.$id, 4);
console.log('EvenDice value (2 | 4 | 6):', roll);
// Odd values are rejected at runtime.
const oddErrors = JsonTology.validate(EvenDiceSchema, 3);
console.log('Errors for 3 (not a multiple of 2):', oddErrors.items.map((err) => {
return err.message;
}));
Use MultipleOfRangeType<Min, Max, Step> as a standalone utility for arbitrary stepped ranges.
not exclusion Compile-time + Runtime
Simple not clauses narrow the inferred type:
/**
* `not` exclusion — removes primitives or values from the inferred type.
*
* `not: { type }` excludes a primitive from a union.
* `not: { const }` removes a specific value from an enum.
* `not: { enum }` removes a set of values from an enum.
*/
import type { InferType } from '../../../src/types/index.js';
// not: { type } - removes primitives from unions
const _NonStringSchema = {
'not': { 'type': 'string' },
'type': [
'string',
'number',
'boolean'
]
} as const;
// boolean | number
type NonString = InferType<typeof _NonStringSchema>;
// not: { const } - removes specific values
const _NonNullStatusSchema = {
'enum': [
'active',
'inactive',
null
],
'not': { 'const': null }
} as const;
// 'active' | 'inactive'
type NonNullStatus = InferType<typeof _NonNullStatusSchema>;
// not: { enum } - removes a set of values
const _RestrictedSchema = {
'enum': [
'a',
'b',
'c',
'd'
],
'not': {
'enum': [
'b',
'c'
]
}
} as const;
// 'a' | 'd'
type Restricted = InferType<typeof _RestrictedSchema>;
// Runtime demonstration: values that satisfy each narrowed type
const nonString: NonString = 42;
const nonNullStatus: NonNullStatus = 'active';
const restricted: Restricted = 'a';
console.log('NonString (boolean | number):', nonString);
console.log('NonNullStatus (active | inactive):', nonNullStatus);
console.log('Restricted (a | d):', restricted);
propertyNames: { enum } strict keys Compile-time
When propertyNames specifies an enum, the object keys are narrowed to that union:
import type {
InferType, ValidationErrorType
} from '../../../src/types/index.js';
import { JsonTology } from '../../../src/index.js';
const ConfigSchema = {
'$id': 'urn:brands:Config',
'additionalProperties': { 'type': 'string' },
'propertyNames': {
'enum': [
'host',
'port',
'debug'
]
},
'type': 'object'
} as const;
type Config = InferType<typeof ConfigSchema>;
// { readonly host?: string; readonly port?: string; readonly debug?: string }
// The inferred type only permits the three enum keys.
const config: Config = {
'host': 'localhost',
'port': '8080'
};
console.log('Config with narrowed keys:', config);
// An unknown key fails runtime validation.
const unknownKeyErrors = JsonTology.validate(ConfigSchema, { 'timeout': '30' });
console.log('Errors for unknown key "timeout":', unknownKeyErrors.items.map((err: ValidationErrorType) => {
return err.message;
}));
patternProperties template literal keys Compile-time
Anchored regex patterns are converted to TypeScript template literal types. Four pattern shapes are recognised:
| Pattern | Inferred key type |
|---|---|
^data_ | `data_${string}` |
_id$ | `${string}_id` |
^exact$ | 'exact' (literal) |
^(a|b|c)$ | 'a' | 'b' | 'c' (literal union) |
^[class]+suffix$ | `${string}suffix` |
^.{N}$ (N ≤ 8) | length-N template literal |
| Other patterns | string (fallback) |
import type { InferType } from '../../../src/types/index.js';
const _MetadataSchema = {
'patternProperties': {
'^data_': { 'type': 'string' },
'^meta_': { 'type': 'number' }
},
'type': 'object'
} as const;
type Metadata = InferType<typeof _MetadataSchema>;
// compiles
const _ok: Metadata = {
'data_name': 'Bastian',
'meta_version': 1
};
// compile error — `data_`-prefixed keys must be string, not number.
// @ts-expect-error number is not assignable to the string-typed `data_` pattern key
const _bad: Metadata = { 'data_age': 99 };
console.log('patternProperties — valid object:', _ok);
// _bad is held via @ts-expect-error — the runtime value is { data_age: 99 }.
console.log('patternProperties — invalid (number under data_ key):', _bad);
Multiple patternProperties entries are intersected so each pattern enforces its own value type.
if/then/else generalised narrowing Compile-time + Runtime
if/then/else narrowing recognises three property forms in if.properties: { const: V }, { enum: [...] }, and { type: 'string' | 'number' | ... }. Multi-property discriminators intersect all literals at once. Every property in if.properties must appear in required for narrowing to apply; otherwise the inferred type is the union of both branches.
For a single const discriminator:
import type { InferType } from '../../../src/types/index.js';
const _ShapeSchema = {
'else': {
'properties': { 'width': { 'type': 'number' } },
'required': ['width']
},
'if': {
'properties': { 'kind': { 'const': 'circle' } },
'required': ['kind']
},
'properties': { 'kind': { 'type': 'string' } },
'required': ['kind'],
'then': {
'properties': { 'radius': { 'type': 'number' } },
'required': ['radius']
},
'type': 'object'
} as const;
type Shape = InferType<typeof _ShapeSchema>;
// Union of:
// { kind: 'circle'; radius: number; ... } - then branch, kind narrowed to 'circle'
// | { kind: string; width: number; ... } - else branch
// Representative runtime values for each discriminated branch.
const circle: Shape = {
'kind': 'circle',
'radius': 5
};
const rectangle: Shape = {
'kind': 'rect',
'width': 10
};
console.log('if/then branch (circle, radius narrowed):', circle);
console.log('else branch (rect, width required):', rectangle);
Multi-property discriminator example:
/**
* Multi-key discriminator narrowing — InferType handles `if` clauses
* with multiple required `const`-pinned properties. The result is a
* discriminated union narrowed on every if-pinned key. `then` here
* is the JSON Schema keyword, not a Promise method.
*/
import type { InferType } from '../../../src/types/index.js';
const _MultiDiscriminatorSchema = {
'else': {
'properties': { 'width': { 'type': 'number' } },
'required': ['width']
},
'if': {
'properties': {
'color': { 'const': 'red' },
'kind': { 'const': 'circle' }
},
'required': [
'kind',
'color'
]
},
'properties': {
'color': { 'type': 'string' },
'kind': { 'type': 'string' }
},
'required': [
'kind',
'color'
],
'then': {
'properties': { 'radius': { 'type': 'number' } },
'required': ['radius']
},
'type': 'object'
} as const;
type MultiShape = InferType<typeof _MultiDiscriminatorSchema>;
// Representative values for each discriminated branch.
const redCircle: MultiShape = {
'color': 'red',
'kind': 'circle',
'radius': 7
};
const other: MultiShape = {
'color': 'blue',
'kind': 'square',
'width': 4
};
console.log('if/then branch (kind=circle, color=red, radius narrowed):', redCircle);
console.log('else branch (other kind/color, width required):', other);
dependentRequired conditional typing Compile-time + Runtime
Modeled as a per-trigger union. When the trigger key is present, all its dependents become required:
import type { InferType } from '../../../src/types/index.js';
const _PaymentSchema = {
'dependentRequired': { 'credit_card': ['billing_address'] },
'properties': {
'billing_address': { 'type': 'string' },
'credit_card': { 'type': 'string' }
},
'type': 'object'
} as const;
type Payment = InferType<typeof _PaymentSchema>;
// Either:
// { credit_card?: never; billing_address?: string } - no credit card, address optional
// | { billing_address: unknown; ... } - credit card present → address required
// No credit card — billing_address is optional.
const withoutCard: Payment = { 'billing_address': '123 Main St' };
// Credit card present — billing_address is required in the type.
const withCard: Payment = {
'billing_address': '123 Main St',
'credit_card': '4111111111111111'
};
console.log('Payment without credit card:', withoutCard);
console.log('Payment with credit card (billing_address required):', withCard);
uniqueItems tuple distinctness Compile-time + Runtime
uniqueItems: true is enforced at two compile-time levels depending on the array shape:
Homogeneous arrays - the inferred type carries
UniqueArrayBrandInterface<T>(a generic uniqueness brand parameterised by element type). A plainT[]cannot satisfy it; values must come throughJsonTology.instantiate/ coerce /materialize.Literal-typed tuples (≤ 8 elements via
prefixItems) -UniqueTuplePairwiseTyperuns a pairwise overlap check at the type level and collapses the tuple toneverwhen any pair of element types overlaps. This means{ prefixItems: [{ const: 'red' }, { const: 'red' }], uniqueItems: true }is a compile-time error.
Above 8 elements the pairwise check is skipped and runtime validation still enforces uniqueItems.
Note: Compile-time tuple pairwise checking applies only to literal-typed tuples declared via
prefixItemswith ≤ 8 elements. Homogeneous arrays (e.g.string[]) receive only theUniqueArrayBrandInterface<T>brand and rely on runtime enforcement for actual uniqueness - there is no compile-time element-by-element comparison for homogeneous arrays.
import type { InferType } from '../../../src/types/index.js';
const _DuplicateConstTuple = {
'prefixItems': [
{ 'const': 'red' },
// duplicate — same literal type
{ 'const': 'red' }
],
'type': 'array',
'uniqueItems': true
} as const;
type DuplicateTuple = InferType<typeof _DuplicateConstTuple>;
// never — the pairwise check detected the overlap at compile time
// DuplicateTuple is never: two identical const literals in a uniqueItems tuple
// are incompatible at the type level. The extends-never check confirms this.
type IsDuplicateTupleNever = [DuplicateTuple] extends [never] ? true : false;
const check: IsDuplicateTupleNever = true;
console.log('Duplicate const tuple collapses to never:', check);
tightStringLengths opt-in narrowing Compile-time
When a project augments JsonTologyTypeConfigInterface with 'tightStringLengths': true, InferType narrows strings whose minLength/maxLength bounds are within StringLengthCap = 8 to a union of fixed-length character template literals.
// json-tology.d.ts — opt in to tight string length narrowing
declare module 'json-tology/types' {
interface JsonTologyTypeConfigInterface { 'tightStringLengths': true }
}import type { InferType } from '../../../src/types/index.js';
const _ThreeCharSchema = {
'maxLength': 3,
'minLength': 3,
'type': 'string'
} as const;
type ThreeChar = InferType<typeof _ThreeCharSchema>;
// `${string}${string}${string}` — exactly 3 characters
// A 3-character literal satisfies the template literal type.
const code: ThreeChar = 'ABC' as ThreeChar;
console.log('ThreeChar value:', code);
// The inferred type requires tightStringLengths opt-in; log the schema bounds.
console.log('minLength:', _ThreeCharSchema.minLength, 'maxLength:', _ThreeCharSchema.maxLength);
import type { InferType } from '../../../src/types/index.js';
const _OneToThreeSchema = {
'maxLength': 3,
'minLength': 1,
'type': 'string'
} as const;
type OneToThree = InferType<typeof _OneToThreeSchema>;
// `${string}` | `${string}${string}` | `${string}${string}${string}`
// Each length within the 1..3 bound produces a separate template literal member.
const one: OneToThree = 'A' as OneToThree;
const two: OneToThree = 'AB' as OneToThree;
const three: OneToThree = 'ABC' as OneToThree;
console.log('1-char member:', one);
console.log('2-char member:', two);
console.log('3-char member:', three);
console.log('minLength:', _OneToThreeSchema.minLength, 'maxLength:', _OneToThreeSchema.maxLength);
Bounds above the cap (or with the flag disabled) fall back to plain string. The flag is default-off so existing schemas pay no compile cost.
See also
- Branded Keywords - string, number, array, object, and nominal brands
- Type Inference - how
InferTyperesolves narrowings and brand intersections - Composition - discriminated union -
if/then/elsein schema composition - Bookstore domain - real-world narrowing examples