X-Value
X-Value (X stands for "cross") is a medium-somewhat-neutral runtime type validation library.
Comparing to alternatives like io-ts and Zod, X-Value uses medium/value concept and allows values to be decoded from and encoded to different mediums.
Table of Contents
Installation
npm install x-value
Quick Start
Runtime Type Validation
import * as x from 'x-value';
const Payload = x.object({
date: x.Date,
limit: x.number.optional(),
});
type Payload = x.TypeOf<typeof Payload>;
const valid = Payload.is({});
const issues = Payload.diagnose({});
const value = Payload.satisfies({});
Payload.asserts({});
Basic Command-Line Parsing
import * as x from 'x-value';
const {file, force = false} = x
.object({
file: x.string,
force: x.boolean.optional(),
})
.decode(x.commandLine, process.argv.slice(2));
JSON Schema
import * as x from 'x-value';
const Config = x
.object({
build: x.union([x.literal('debug'), x.literal('release')]).nominal({
description: "Build type, 'debug' for debug and 'release' for release.",
}),
port: x
.integerRange({min: 1, max: 65535})
.nominal({
description: 'Port to listen.',
})
.optional(),
})
.exact();
const jsonSchema = Config.toJSONSchema();
Multi-medium Usages
import * as x from 'x-value';
declare global {
namespace XValue {
interface Using
extends x.UsingExtendedJSONValue,
x.UsingExtendedQueryString {}
}
}
const Payload = x.object({
date: x.Date,
limit: x.number,
});
Payload.decode(x.extendedJSONValue, {
date: '1970-01-01T00:00:00.000Z',
limit: 10,
});
Payload.decode(x.extendedQueryString, 'date=1970-01-01T00:00:00.000Z&limit=10');
Types
Atomic Type
Atomic types are elementary types that build other types.
To define an atomic type, a symbol to decoded type mapping is also required:
declare global {
namespace XValue {
interface Types {
[stringTypeSymbol]: string;
}
}
}
export const stringTypeSymbol = Symbol();
export const string = x.atomic(stringTypeSymbol, value =>
x.constraint(typeof value === 'string'),
);
The symbol to type mapping is also required for mediums that supports this atomic type.
Built-in Atomic Types
x.never
x.unknown
x.undefined
x.void
x.null
x.string
x.number
x.bigint
x.boolean
x.Function
x.Date
x.RegExp
Object Type
const ObjectType = x.object({
foo: x.string,
bar: x.number.optional(),
});
type ObjectType = x.TypeOf<typeof ObjectType>;
The return value of .optional()
is an instance of TypeLike
instead of Type
.
To extend an object type:
const ExtendedObjectType = ObjectType.extend({
extra: x.boolean,
});
Record Type
const RecordType = x.record(x.string, x.number);
type RecordType = x.TypeOf<typeof RecordType>;
Array Type
const ArrayType = x.array(x.string);
type ArrayType = x.TypeOf<typeof ArrayType>;
Tuple Type
const TupleType = x.tuple([x.string, x.number, x.boolean.optional()]);
type TupleType = x.TypeOf<typeof TupleType>;
Union Type
const UnionType = x.union([x.boolean, x.undefined]);
type UnionType = x.TypeOf<typeof UnionType>;
At least two types are required for union type.
Intersection Type
const IntersectionType = x.intersection([
x.object({
foo: x.string,
}),
x.object({
bar: x.number.optional(),
}),
]);
type IntersectionType = x.TypeOf<typeof IntersectionType>;
At least two types are required for intersection type.
Recursive Type
Recursive type requires a hand-written definition:
interface RecursiveTypeDefinition {
date: typeof x.Date;
next?: RecursiveTypeDefinition;
}
const RecursiveType = x.recursive<RecursiveTypeDefinition>(RecursiveType =>
x.object({
date: x.Date,
next: RecursiveType.optional(),
}),
);
type RecursiveType = x.TypeOf<typeof RecursiveType>;
However, you don't have to write the whole declaration separately for type that contains recursive part:
const NonRecursivePart = x.object({
date: x.Date,
});
type RecursiveTypeDefinition = x.Recursive<
{
next?: RecursiveTypeDefinition;
},
typeof NonRecursivePart
>;
const RecursiveType = x.recursive<RecursiveTypeDefinition>(RecursiveType =>
NonRecursivePart.extend({
next: RecursiveType.optional(),
}),
);
type RecursiveType = x.TypeOf<typeof RecursiveType>;
The hand-written RecursiveTypeDefinition
is completely different from the one built by x.Recursive<>
, you may choose what fits your needs more.
Refined Type
const RefinedType = x.string.refined(value =>
x.refinement(value.includes('@'), value),
);
Type.refined()
accepts two generic type parameters: TNominalKey
and TRefinement
.
TNominalKey
is a string or symbol that identifies the type, use never
if you don't want to specify that.TRefinement
is the type refinement that will eventually be used to intersect with the original one (T & TRefinement
).
E.g.:
const RefinedType = x.string.refined<never, `${string}@${string}`>(value =>
x.refinement(value.includes('@'), value),
);
const RefinedType = x.string.refined<'email'>(value =>
x.refinement(value.includes('@'), value),
);
We can also change the refined value by returning a different one:
const TrimmedString = x.string.refined(value => value.trim());
The refine process happens during both encode/decode phases, and is supposed to be a stable process. Which means that refining against an already-refined value should return an identical one.
Nominal Type
Nominal type is just refined type with only nominal key and no refinements:
const RefinedType = x.string.nominal<'email'>();
Exact Type
X-Value by default parses only known properties. However, the extra properties are ignored without throwing errors.
To make sure type guards and assertions work as expected, you may use Type.exact()
if needed.
const ExactType = x
.object({
foo: x.object({
bar: x
.object({
pia: x.string,
})
.exact(false),
}),
})
.exact();
Type.exact()
will be inherited unless explicitly .exact(false)
.
Type Usages
Decode from Medium
declare global {
namespace XValue {
interface Using extends x.UsingJSON {}
}
}
const Data = x.object({
foo: x.string,
bar: x.number,
});
Data.decode(x.json, '{"foo":"abc","bar":123}');
Encode to Medium
declare global {
namespace XValue {
interface Using extends x.UsingJSON {}
}
}
const Data = x.object({
foo: x.string,
bar: x.number,
});
Data.encode(x.json, {foo: 'abc', bar: 123});
Transform from Medium to Medium
declare global {
namespace XValue {
interface Using extends x.UsingJSON, x.UsingQueryString {}
}
}
const Data = x.object({
foo: x.string,
bar: x.number,
});
Data.transform(x.queryString, x.json, 'foo=abc&bar=123');
Sanitize Value
const Data = x.object({
foo: x.string,
bar: x.number,
});
Data.sanitize({foo: 'abc', bar: 123, extra: true});
Type Guards
if (Type.is(value)) {
}
const sameValue = Type.satisfies(value);
Type.asserts(value);
Type Diagnostics
const issues = Data.diagnose(value);
Static Type
declare global {
namespace XValue {
interface Using extends x.UsingJSON {}
}
}
const Data = x.object({
foo: x.string,
bar: x.number,
});
type Data = x.TypeOf<typeof Data>;
type DataInJSON = x.MediumTypeOf<'json', typeof Data>;
type TypeOfValueBeingData = x.XTypeOfValue<string>;
type TypeOfMediumValueBeingData = x.XTypeOfMediumValue<'json-value', string>;
JSON Schema
X-Value has built-in (basic) support for JSON Schema.
const Data = x.object({
foo: x.string,
bar: x.number,
});
Data.toJSONSchema();
Data.exact().toJSONSchema();
Medium
Built-in Mediums
x.ecmascript
- Basically the same as the decoded value, but can be extended for different usages (e.g.: server and browser).x.json
- JSON value packed as string.x.extendedJSON
- JSON value packed as string, with extended types support (bigint
, Date
and RegExp
).x.jsonValue
- JSON value.x.extendedJSONValue
- JSON value, with extended types support (bigint
, Date
and RegExp
).x.queryString
- Query string packed as string.x.extendedQueryString
- Query string packed as string, with extended types support (bigint
, Date
and RegExp
).x.commandLine
- Command line arguments packed as string.
Command-Line Medium Example
Please note that command-line parsing is not directly relevant to X-Value. I build it into X-Value because it's super lightweight and the usage intersects one of X-Value's major scenarios, i.e., config validation.
const [name, date] = x
.tuple([x.string, x.Date])
.decode(x.commandLine, process.argv.slice(2));
const {name, date} = x
.object({
name: x.string,
date: x.Date,
})
.decode(x.commandLine, process.argv.slice(2));
const args = x
.intersection([
x.tuple([x.string]),
x.object({
from: x.Date,
to: x.Date.optional(),
}),
])
.decode(x.commandLine, process.argv.slice(2));
const [name] = args;
const {from, to = new Date()} = args;
New Medium
New medium are usually created with new atomic types.
New atomic type
declare global {
namespace XValue {
interface Types {
[identifierTypeSymbol]: string;
}
}
}
const identifierTypeSymbol = Symbol();
export const Identifier = x.atomic(identifierTypeSymbol, value =>
x.constraint(typeof value === 'string'),
);
export type Identifier = x.TypeOf<typeof Identifier>;
New medium
export interface UsingMyMedium {
'my-medium': MyMediumTypes;
}
interface MyMediumTypes extends x.ECMAScriptTypes {
[identifierTypeSymbol]: IdentifierInMyMedium;
}
interface IdentifierInMyMedium extends Buffer {
toString(encoding: 'hex'): x.TransformNominal<this, string>;
}
export const myMedium = x.ecmascript.extend<UsingMyMedium>({
codecs: {
[identifierTypeSymbol]: {
encode(value) {
if (value.length === 0) {
throw 'Value cannot be empty string';
}
return Buffer.from(value, 'hex');
},
decode(value) {
if (!Buffer.isBuffer(value)) {
throw 'Value must be a buffer';
}
return value.toString('hex');
},
},
},
});
To use this medium:
declare global {
namespace XValue {
interface Using extends UsingMyMedium {}
}
}
Medium Packing
X-Value can optionally unpacks data for a structured input (e.g., JSON.parse()
) during decode()
and packs the data again during encode()
(e.g., JSON.stringify()
).
For medium that requires packing (e.g., x.json
and x.queryString
), different configuration is required.
export interface UsingMyPacked {
'my-packed': MyPackedTypes;
}
interface MyPackedTypes {
packed: string;
}
const packed = x.medium<UsingMyPacked>({
packing: {
pack(data) {
return JSON.stringify(data);
},
unpack(json) {
return JSON.parse(json);
},
},
codecs: {
[atomicTypeSymbol]: {
encode(value) {
return value;
},
decode(value) {
return value;
},
},
},
});
Mediums and Values
Mediums are what's used to store values: JSON strings, query strings, buffers etc.
For example, a string "2022-03-31T16:00:00.000Z"
in JSON medium with type Date
represents value new Date('2022-03-31T16:00:00.000Z')
.
Assuming we have 3 mediums: browser
, server
, rpc
; and 2 types: ObjectId
, Date
. Their types in mediums and value are listed below.
Type\Medium | Browser | RPC | Server | Value |
---|
ObjectId | string | packed as string | ObjectId | string |
Date | Date | packed as string | Date | Date |
We can encode values to mediums:
const id = '6246056b1be8cbf6ca18401f';
ObjectId.encode(browser, id);
ObjectId.encode(rpc, id);
ObjectId.encode(server, id);
const date = new Date('2022-03-31T16:00:00.000Z');
Date.encode(browser, date);
Date.encode(rpc, date);
Date.encode(server, date);
Or decode packed data of mediums to values:
ObjectId.decode(browser, '6246056b1be8cbf6ca18401f');
ObjectId.decode(rpc, '"6246056b1be8cbf6ca18401f"');
ObjectId.decode(server, new ObjectId('6246056b1be8cbf6ca18401f'));
Date.decode(browser, new Date('2022-03-31T16:00:00.000Z'));
Date.decode(rpc, '"2022-03-31T16:00:00.000Z"');
Date.decode(server, new Date('2022-03-31T16:00:00.000Z'));
Ideally there's no need to have "value" as a separate concept because it's essentially "ECMAScript runtime medium". But to make decode/encode easier among different mediums, "value" is promoted as an interchangeable medium.
License
MIT License.