Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/vilicvane/x-value

A medium-neutral runtime type validation library.
https://github.com/vilicvane/x-value

runtime typescript validation

Last synced: 20 days ago
JSON representation

A medium-neutral runtime type validation library.

Awesome Lists containing this project

README

        

[![NPM version](https://img.shields.io/npm/v/x-value?color=%23cb3837&style=flat-square)](https://www.npmjs.com/package/x-value)
[![Repository package.json version](https://img.shields.io/github/package-json/v/vilic/x-value?color=%230969da&label=repo&style=flat-square)](./package.json)
[![Coverage](https://img.shields.io/coveralls/github/vilic/x-value?style=flat-square)](https://coveralls.io/github/vilic/x-value)
[![MIT License](https://img.shields.io/badge/license-MIT-999999?style=flat-square)](./LICENSE)
[![Discord](https://img.shields.io/badge/chat-discord-5662f6?style=flat-square)](https://discord.gg/vanVrDwSkS)

# X-Value

X-Value (X stands for "cross") is a **medium**-somewhat-**neutral** runtime type validation library.

Comparing to alternatives like [io-ts](https://github.com/gcanti/io-ts) and [Zod](https://github.com/colinhacks/zod), X-Value uses medium/value concept and allows values to be decoded from and encoded to different mediums.

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [Runtime Type Validation](#runtime-type-validation)
- [Basic Command-Line Parsing](#basic-command-line-parsing)
- [JSON Schema](#json-schema)
- [Multi-medium Usages](#multi-medium-usages)
- [Types](#types)
- [Atomic Type](#atomic-type)
- [Built-in Atomic Types](#built-in-atomic-types)
- [Object Type](#object-type)
- [Record Type](#record-type)
- [Array Type](#array-type)
- [Tuple Type](#tuple-type)
- [Union Type](#union-type)
- [Intersection Type](#intersection-type)
- [Recursive Type](#recursive-type)
- [Refined Type](#refined-type)
- [Nominal Type](#nominal-type)
- [Exact Type](#exact-type)
- [Type Usages](#type-usages)
- [Decode from Medium](#decode-from-medium)
- [Encode to Medium](#encode-to-medium)
- [Transform from Medium to Medium](#transform-from-medium-to-medium)
- [Sanitize Value](#sanitize-value)
- [Type Guards](#type-guards)
- [Type Diagnostics](#type-diagnostics)
- [Static Type](#static-type)
- [JSON Schema](#json-schema-1)
- [Medium](#medium)
- [Built-in Mediums](#built-in-mediums)
- [Command-Line Medium Example](#command-line-medium-example)
- [New Medium](#new-medium)
- [Medium Packing](#medium-packing)
- [Mediums and Values](#mediums-and-values)
- [License](#license)

## Installation

```sh
npm install x-value
```

## Quick Start

### Runtime Type Validation

```ts
import * as x from 'x-value';

// Define X-Type.
const Payload = x.object({
date: x.Date,
limit: x.number.optional(),
});

// Get static type of Payload.
type Payload = x.TypeOf;

// Returns true if payload is a valid value of Payload.
const valid = Payload.is({});

// Returns an array of issues if payload is not a valid value of Payload, empty
// if valid.
const issues = Payload.diagnose({});

// Returns valid value as-is, throws if invalid.
const value = Payload.satisfies({});

// Asserts payload, throws if invalid.
Payload.asserts({});
```

### Basic Command-Line Parsing

```ts
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));

// Command line:
// command --file=example.txt --force
```

### JSON Schema

```ts
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();

// JSON schema (object).
const jsonSchema = Config.toJSONSchema();
```

### Multi-medium Usages

```ts
import * as x from 'x-value';

declare global {
namespace XValue {
/**
* X-Value caches static types to improve compilation performance. To avoid
* unnecessary overhead, we need explicit declarations of mediums being
* used.
*
* In this example, we use "extended-json-value" and "extended-query-string"
* mediums.
*/
interface Using
extends x.UsingExtendedJSONValue,
x.UsingExtendedQueryString {}
}
}

const Payload = x.object({
date: x.Date,
limit: x.number,
});

// Decode from "extended-json-value", with which `Date` is encoded as string.
Payload.decode(x.extendedJSONValue, {
date: '1970-01-01T00:00:00.000Z',
limit: 10,
});

// Decode from "extended-query-string", with which both `Date` and `number` are
// encoded as string and then "packed" together as a string.
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:

```ts
declare global {
namespace XValue {
/**
* `XValue.Types` is an interface that maps atomic type symbol to the
* correspondent type in decoded value.
*/
interface Types {
[stringTypeSymbol]: string;
}
}
}

export const stringTypeSymbol = Symbol();

export const string = x.atomic(stringTypeSymbol, value =>
// `x.constraint` is a helper function that throws on false condition.
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

```ts
const ObjectType = x.object({
foo: x.string,
bar: x.number.optional(),
});

type ObjectType = x.TypeOf; // {foo: string; bar?: number}
```

> The return value of `.optional()` is an instance of `TypeLike` instead of `Type`.

To extend an object type:

```ts
const ExtendedObjectType = ObjectType.extend({
extra: x.boolean,
});
```

### Record Type

```ts
const RecordType = x.record(x.string, x.number);

type RecordType = x.TypeOf; // {[key: string]: number}
```

### Array Type

```ts
const ArrayType = x.array(x.string);

type ArrayType = x.TypeOf; // string[]
```

### Tuple Type

```ts
const TupleType = x.tuple([x.string, x.number, x.boolean.optional()]);

type TupleType = x.TypeOf; // [string, number, boolean?]
```

### Union Type

```ts
const UnionType = x.union([x.boolean, x.undefined]);

type UnionType = x.TypeOf; // boolean | undefined
```

> At least two types are required for union type.

### Intersection Type

```ts
const IntersectionType = x.intersection([
x.object({
foo: x.string,
}),
x.object({
bar: x.number.optional(),
}),
]);

type IntersectionType = x.TypeOf; // {foo: string; bar?: number}
```

> At least two types are required for intersection type.

### Recursive Type

Recursive type requires a hand-written definition:

```ts
/**
* This is required for recursive type to work.
*/
interface RecursiveTypeDefinition {
// Use `typeof x.Date` to make sure it works with different mediums.
date: typeof x.Date;
next?: RecursiveTypeDefinition;
}

const RecursiveType = x.recursive(RecursiveType =>
x.object({
date: x.Date,
next: RecursiveType.optional(),
}),
);

type RecursiveType = x.TypeOf;
```

However, you don't have to write the whole declaration separately for type that contains recursive part:

```ts
const NonRecursivePart = x.object({
date: x.Date,
});

// Use `x.Recursive<>` to build recursive type definition.
type RecursiveTypeDefinition = x.Recursive<
{
next?: RecursiveTypeDefinition;
},
typeof NonRecursivePart
>;

const RecursiveType = x.recursive(RecursiveType =>
NonRecursivePart.extend({
next: RecursiveType.optional(),
}),
);

type RecursiveType = x.TypeOf;
```

> The hand-written `RecursiveTypeDefinition` is completely different from the one built by `x.Recursive<>`, you may choose what fits your needs more.

### Refined Type

```ts
const RefinedType = x.string.refined(value =>
// `x.refinement` is a helper function that returns the refined value on true
// condition while throws on false condition.
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.:

```ts
// No nominal key but refinement on type:
const RefinedType = x.string.refined(value =>
x.refinement(value.includes('@'), value),
);

// Nominal key but no refinement on type:
const RefinedType = x.string.refined<'email'>(value =>
x.refinement(value.includes('@'), value),
);
```

We can also change the refined value by returning a different one:

```ts
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](#refined-type) with only nominal key and no refinements:

```ts
const RefinedType = x.string.nominal<'email'>(); // x.string.refined<'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.

```ts
const ExactType = x
.object({
// exact: true
foo: x.object({
// exact: inherited true
bar: x
.object({
// exact: false
pia: x.string,
})
.exact(false),
}),
})
.exact();
```

> `Type.exact()` will be inherited unless explicitly `.exact(false)`.

## Type Usages

### Decode from Medium

```ts
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}'); // {foo: 'abc', bar: 123}
```

### Encode to Medium

```ts
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}); // '{"foo":"abc","bar":123}'
```

### Transform from Medium to Medium

```ts
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'); // '{"foo":"abc","bar":123}'
```

### Sanitize Value

```ts
const Data = x.object({
foo: x.string,
bar: x.number,
});

Data.sanitize({foo: 'abc', bar: 123, extra: true}); // {foo: 'abc', bar: 123}
```

### Type Guards

```ts
if (Type.is(value)) {
// `value` narrowed to `x.TypeOf`.
}
```

```ts
const sameValue = Type.satisfies(value); // Returns `value` as-is if it satisfies, otherwise throws.
```

```ts
Type.asserts(value); // Asserts `value` is `x.TypeOf`.
```

### Type Diagnostics

```ts
const issues = Data.diagnose(value);
```

### Static Type

```ts
declare global {
namespace XValue {
interface Using extends x.UsingJSON {}
}
}

const Data = x.object({
foo: x.string,
bar: x.number,
});

type Data = x.TypeOf; // {foo: string; bar: number}
type DataInJSON = x.MediumTypeOf<'json', typeof Data>; // string

/** Represents a `Type` of which the decoded value is string. */
type TypeOfValueBeingData = x.XTypeOfValue;

/** Represents a `Type` of which the decoded value for "json-value" medium is string. */
type TypeOfMediumValueBeingData = x.XTypeOfMediumValue<'json-value', string>;
```

### JSON Schema

X-Value has built-in (basic) support for [JSON Schema](https://json-schema.org/).

```ts
const Data = x.object({
foo: x.string,
bar: x.number,
});

Data.toJSONSchema(); // JSON schema
Data.exact().toJSONSchema(); // JSON schema that prohibits extra properties
```

## 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.

```ts
// Positional arguments

const [name, date] = x
.tuple([x.string, x.Date])
.decode(x.commandLine, process.argv.slice(2));
```

```ts
// Named arguments

const {name, date} = x
.object({
name: x.string,
date: x.Date,
})
.decode(x.commandLine, process.argv.slice(2));
```

```ts
// Or both

const args = x
.intersection([
// In this case, tuple must come first.
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**

```ts
declare global {
namespace XValue {
interface Types {
// Map the decoded identifier type as string.
[identifierTypeSymbol]: string;
}
}
}

const identifierTypeSymbol = Symbol();

export const Identifier = x.atomic(identifierTypeSymbol, value =>
x.constraint(typeof value === 'string'),
);

export type Identifier = x.TypeOf;
```

**New medium**

```ts
// This is for `XValue.Using` interface to extend.
export interface UsingMyMedium {
// 'my-medium' is the name in `x.MediumTypeOf<'my-medium', typeof Type>`.
'my-medium': MyMediumTypes;
}

// Atomic type mapping for "my-medium".
interface MyMediumTypes extends x.ECMAScriptTypes {
// Map the encoded identifier type in "my-medium" as `IdentifierInMyMedium`.
[identifierTypeSymbol]: IdentifierInMyMedium;
}

interface IdentifierInMyMedium extends Buffer {
// Override the `toString(encoding: 'hex')` signature to preserve nominal
// type.
toString(encoding: 'hex'): x.TransformNominal;
}

// Create the medium object with extended codecs.
export const myMedium = x.ecmascript.extend({
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:

```ts
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.

```ts
export interface UsingMyPacked {
'my-packed': MyPackedTypes;
}

interface MyPackedTypes {
// Define the packed type instead of atomic type symbol mapping.
packed: string;
}

const packed = x.medium({
// Define packing methods.
packing: {
pack(data) {
return JSON.stringify(data);
},
unpack(json) {
return JSON.parse(json);
},
},
// Optionally define the codec for packed medium. Use `atomicTypeSymbol` to
// catch all atomic types without explicit codec.
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:

```ts
const id = '6246056b1be8cbf6ca18401f';

ObjectId.encode(browser, id); // string '6246056b1be8cbf6ca18401f'
ObjectId.encode(rpc, id); // packed string '"6246056b1be8cbf6ca18401f"'
ObjectId.encode(server, id); // new ObjectId('6246056b1be8cbf6ca18401f')

const date = new Date('2022-03-31T16:00:00.000Z');

Date.encode(browser, date); // new Date('2022-03-31T16:00:00.000Z')
Date.encode(rpc, date); // packed string '"2022-03-31T16:00:00.000Z"'
Date.encode(server, date); // new Date('2022-03-31T16:00:00.000Z')
```

Or decode packed data of mediums to values:

```ts
// All result in '6246056b1be8cbf6ca18401f'
ObjectId.decode(browser, '6246056b1be8cbf6ca18401f');
ObjectId.decode(rpc, '"6246056b1be8cbf6ca18401f"');
ObjectId.decode(server, new ObjectId('6246056b1be8cbf6ca18401f'));

// All result in new Date('2022-03-31T16:00:00.000Z')
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.