Skip to content

VRefine adds JSON Schema constraints to any type - primitives, arrays, and objects alike.

ts
VRefine<number, { minimum: 1000, maximum: 1_000_000 }>

It is globally available - no import needed.

The first argument is the base type, the second is any valid JSON Schema validation keyword. The full keyword set, grouped as in the spec, is below - everything at a glance.

Validation keywords ​

Strings ​

KeywordMeaning
minLengthMinimum character count
maxLengthMaximum character count
patternMust match the ECMA-262 regular expression
formatMust match a named format - see the format table
ts
VRefine<string, { minLength: 3, maxLength: 50 }>
VRefine<string, { pattern: "^[a-z0-9][a-z0-9_-]*$" }>
VRefine<string, { format: "email" }>

Numbers ​

KeywordMeaning
minimumInclusive lower bound
maximumInclusive upper bound
exclusiveMinimumExclusive lower bound
exclusiveMaximumExclusive upper bound
multipleOfMust be evenly divisible by the given value
ts
VRefine<number, { minimum: 1, maximum: 100, multipleOf: 1 }>
VRefine<number, { exclusiveMinimum: 0 }>

Arrays ​

KeywordMeaning
minItemsMinimum element count
maxItemsMaximum element count
uniqueItemsAll elements must be distinct
containsAt least one element must match the given schema, written inline as a schema literal
minContains / maxContainsBounds on how many elements may match contains
ts
VRefine<Array<string>, { minItems: 1, maxItems: 20 }>
VRefine<Array<VRefine<string, { format: "email" }>>, { uniqueItems: true }>

// at least one "admin" entry
VRefine<string[], { contains: { type: "string"; const: "admin" } }>

// one or two elements matching the nested schema - any keyword combination works,
// including pattern, enum, format, length bounds
VRefine<string[], {
  contains: { type: "string"; const: "premium" };
  minContains: 1;
  maxContains: 2;
}>

Objects ​

KeywordMeaning
minPropertiesMinimum property count
maxPropertiesMaximum property count
requiredArray of mandatory property names - normally implied by TypeScript optionality (?), reach for it only on open shapes like Record
dependentRequiredProperties required when another property is present
ts
VRefine<Record<string, string>, { maxProperties: 20 }>

Any instance type ​

KeywordMeaning
enumMust equal one of the listed values - as a top-level constraint a TypeScript literal union ("a" | "b") expresses this natively; inside a nested schema like contains it is the way to say it
constMust equal a specific value - as a top-level constraint a TypeScript literal type ("a", 2, true) expresses this natively; inside a nested schema like contains it is the way to say it
typeConstrains the JSON type - redundant at the top level (the base type already is the type), required inside nested schemas like contains

Content keywords ​

contentEncoding, contentMediaType, and contentSchema describe how to interpret string contents (e.g. base64 payloads). Per the spec they are annotations, not assertions - they document, they don't reject. Fine to attach for OpenAPI output; don't expect them to validate anything.

Formats ​

format: "..." values are validated at runtime. Every format defined by the 2020-12 spec is supported, plus two TypeBox extras at the end:

FormatValidates
date-timeRFC 3339 timestamp, e.g. 2026-08-23T07:00:00Z
dateFull date, e.g. 2026-08-23
timeTime of day, e.g. 07:00:00Z
durationISO 8601 duration, e.g. P3DT4H
emailEmail address
idn-emailInternationalized email address
hostnameDNS hostname
idn-hostnameInternationalized hostname
ipv4IPv4 address
ipv6IPv6 address
uriAbsolute URI
uri-referenceURI or relative reference
iriInternationalized URI
iri-referenceIRI or relative reference
uuidUUID, e.g. f81d4fae-7dec-11d0-a765-00a0c91e6bf6
uri-templateRFC 6570 URI template
json-pointerJSON Pointer, e.g. /foo/0
relative-json-pointerRelative JSON Pointer
regexECMA-262 regular expression source
urlURL (TypeBox extra, not in the spec)
json-pointer-uri-fragmentJSON Pointer in URI fragment form (TypeBox extra)
ts
VRefine<string, { format: "uuid" }>    // params: id: must be a valid UUID
VRefine<string, { format: "email" }>   // json: from -> email: must be a valid email address

Integers ​

One common gotcha: number alone allows decimals. If you need a true integer, use multipleOf: 1 - it means the value must be evenly divisible by 1:

ts
// allows 1000.5 - probably not what you want
VRefine<number, { minimum: 1000, maximum: 1_000_000 }>

// integers only
VRefine<number, { minimum: 1000, maximum: 1_000_000, multipleOf: 1 }>

This matters especially for database IDs, where a float would pass validation but get rejected at the query level - turning a clear validation error into a confusing DB error.

Keep the Wrapping Brackets Literal ​

One rule covers every place KosmoJS reads structure out of your type arguments:

The rule

The wrapping [] and {} must be written literally. Anything inside them can be aliased.

That applies to three positions:

ts
// the VRefine constraint object
VRefine<string, { pattern: "^[A-Z]{3}$" }>

// the params refinement tuple
defineRoute<"users/[id]/[action]", [UserID, UserAction]>

// the response tuple
POST<{ response: [200, "json", User] }>

In each case the brackets stay where you can see them, while the values inside are free to be named types - local or imported:

ts
// ✅ contents aliased, brackets kept
type Pattern = "^[A-Z]{3}$";
VRefine<string, { pattern: Pattern }>

type UserID = VRefine<number, { minimum: 1, multipleOf: 1 }>;
defineRoute<"users/[id]", [UserID]>

type User = { id: number; name: string };
POST<{ response: [200, "json", User] }>
ts
// ❌ the brackets themselves hidden behind an alias
type Params = [UserID, UserAction];
defineRoute<"users/[id]/[action]", Params>

type ResponseT = [200, "json", User];
POST<{ response: ResponseT }>

The base type of VRefine (its first argument) is unrestricted either way - VRefine<MyStringAlias, { ... }> and imported base types resolve normally.

Why the brackets matter ​

These positions are read structurally, from the source: which tuple slot maps to which route parameter, which slot carries the status code versus the body. An alias that hides the brackets gives it an identifier where it expected a shape, and there is nothing to destructure.

That failure is silent, and it differs by position:

PositionIf the brackets are hidden behind an alias
params tuplethe schema does not build - every request is rejected with a 400
response tupleno schema is built at all - response validation never runs, and the route gets no ResponseT entry

Neither raises a compile error, so nothing points at the alias. If a route rejects input you know is valid, or a response you declared is silently not validated, check the brackets first.

This is one of the mistakes that typecheck cleanly and fail at runtime. Silent Failure Checklist ›

Released under the MIT License.