diff --git a/SPECIFICATION.md b/SPECIFICATION.md new file mode 100644 index 00000000..c017ddd1 --- /dev/null +++ b/SPECIFICATION.md @@ -0,0 +1,718 @@ +Version: 0.1.0 +Title: JSON Type Specification +Author: Vadim @streamich Dalecky + +# JSON Type Specification + +## 1. Overview and Motivation + +### What is JSON Type? + +**JSON Type** is a concise, regular, and extensible type system for describing JSON structures, language runtime types, API messages, and more. It is designed to be easy to implement in any programming language, and to serve as a foundation for validation, code generation, documentation, and serialization across platforms and environments. + +### Motivation + +JSON Type addresses several limitations of existing specifications: + +- **JSON Schema** is expressive but often verbose, irregular, and difficult to map to language types. +- **JSON Type Definition (JTD, RFC8927)** is regular but intentionally limited and not extensible. +- **JSON Type** unifies and extends these ideas, supporting: + - Direct mapping to programming language types (TypeScript, Go, Rust, etc.) + - Expressive and regular node types for all common JSON patterns + - Discriminated unions and autodiscriminants for robust, safe polymorphism + - Extensible metadata and custom validation + - Code generation and optimized (JIT) serialization support + - Representation for code, API messages, and binary formats (e.g., Protobuf, CBOR) + +### Quick Example + +A simple user type: + +```json +{ + "kind": "obj", + "title": "User", + "fields": [ + { "kind": "field", "key": "id", "type": { "kind": "str" }, "title": "User ID" }, + { "kind": "field", "key": "name", "type": { "kind": "str" } }, + { "kind": "field", "key": "age", "type": { "kind": "num", "gte": 0 }, "optional": true } + ] +} +``` + +Represents a user object like this: + +```json +{ + "id": "123", + "name": "Alice", + "age": 30 +} +``` + +This schema is readable, language-agnostic, and can be used to generate code, documentation, validators, or serializers in any environment. + +--- + +## 2. Terminology & Conventions + +### Key Terms + +- **Schema:** A JSON object describing a type using JSON Type node(s). +- **Node (Type Node):** A JSON object with a `kind` property that defines a type (e.g., `"kind": "str"`). +- **Kind:** The string value of the `kind` property, indicating the node type. +- **Field:** An object property within an object schema, defined by an object node of `"kind": "field"`. +- **Discriminant / Discriminator:** A property or expression that distinguishes between union or variant types. +- **Autodiscriminant:** A discriminant inferred automatically based on schema structure (see below). +- **Optional:** Indicates a field or node may be omitted. + +### Notation + +- All node types are JSON objects. +- Properties not listed in a node type are ignored unless the node type allows unknown fields. +- Examples use JSON, but the format is language-agnostic. + +--- + +## 3. Core Concepts + +### Node Types and Type System + +Every node in JSON Type has a `kind` property. The `kind` determines the node’s structure and valid properties. + +All nodes support these common optional properties: + +- `title` (string): Short name for documentation. +- `intro` (string): Short introduction or summary. +- `description` (string): Detailed description. +- `id` (string): Unique identifier for referencing the type elsewhere. +- `meta` (object): Arbitrary metadata for code generators, UI, etc. +- `examples` (array): An array of example values for the type. +- `deprecated` (object): Marks a type as deprecated, with an optional message. + +### Metadata and Extensibility + +- `meta` can store any additional information, such as UI hints, custom validation rules, or code generation flags. +- Types may be referenced by `id` via the `ref` node. + +--- + +## 4. Node Types and Their Properties + +### 4.1 Primitive Types + +#### Any (`any`) + +Represents any value (like `any` in TypeScript). + +```json +{ "kind": "any" } +``` + +**Properties:** +- `validator` (string or array): Name(s) of custom validation rules. +- `meta` (object): Custom metadata. + +**Example:** +```json +{ "kind": "any" } +``` + +--- + +#### Boolean (`bool`) + +Represents a boolean value (`true` or `false`). + +```json +{ "kind": "bool" } +``` + +**Properties:** +- `validator` (string or array): Custom validation. + +--- + +#### Number (`num`) + +Represents a number, with optional format and constraints. + +**Properties:** +- `format` (string): Numeric format (`i`, `u`, `f`, `i8`, `u32`, `f64`, etc.) +- `gt` (number): Greater than (exclusive) +- `gte` (number): Greater or equal (inclusive) +- `lt` (number): Less than (exclusive) +- `lte` (number): Less or equal (inclusive) +- `validator` (string or array): Custom validation. + +**Example:** +```json +{ + "kind": "num", + "format": "u32", + "gte": 0, + "lte": 100, + "examples": [ + { "value": 42, "title": "Typical value" }, + { "value": 100, "title": "Maximum value" } + ] +} +``` + +--- + +#### String (`str`) + +Represents a string value. + +**Properties:** +- `format` (string): `"ascii"` or `"utf8"`. +- `ascii` (boolean, deprecated): Use `format: "ascii"`. +- `noJsonEscape` (boolean): String can be emitted without JSON character escaping. +- `min` (number): Minimum length. +- `max` (number): Maximum length. +- `validator` (string or array): Custom validation. + +**Example:** +```json +{ + "kind": "str", + "format": "ascii", + "min": 1, + "max": 64, + "examples": [ + { "value": "Alice", "title": "User name" }, + { "value": "Bob", "title": "Another user" } + ] +} +``` + +--- + +#### Binary (`bin`) + +Represents binary data, encoding another type. + +**Properties:** +- `type` (node): The type encoded in binary. +- `format` (string): Encoding (`json`, `cbor`, `msgpack`, etc.). +- `min` (number): Minimum size in bytes. +- `max` (number): Maximum size in bytes. +- `validator` (string or array): Custom validation. + +**Example:** +```json +{ "kind": "bin", "type": { "kind": "any" }, "format": "cbor" } +``` + +--- + +### 4.2 Composite Types + +#### Array (`arr`) + +Represents an array of elements of a single type. + +**Properties:** +- `type` (node): Element type. +- `min` (number): Minimum number of elements. +- `max` (number): Maximum number of elements. +- `validator` (string or array): Custom validation. + +**Example:** +```json +{ "kind": "arr", "type": { "kind": "num" }, "min": 1, "max": 10 } +``` + +--- + +#### Tuple (`tup`) + +Represents a fixed-length array, each position with its own type. + +**Properties:** +- `types` (array): Array of node types for each position. +- `validator` (string or array): Custom validation. + +**Example:** +```json +{ "kind": "tup", "types": [ { "kind": "str" }, { "kind": "num" } ] } +``` + +--- + +#### Object (`obj`) + +Represents a JSON object with a defined set of fields. `obj` fields are ordered and can be required or optional. Optional fields are usually defined at the end of the `fields` array. Even if in many languages objects are unordered, the order of fields in the schema is a useful feature as the field order can be used in documentation, code generation, and serialization to binary formats. + +**Properties:** +- `fields` (array): Array of field nodes (see below). +- `unknownFields` (boolean, deprecated): Allow fields not listed. +- `encodeUnknownFields` (boolean): Emit unknown fields during encoding. +- `validator` (string or array): Custom validation. + +**Example:** +```json +{ + "kind": "obj", + "fields": [ + { "kind": "field", "key": "id", "type": { "kind": "str" } }, + { "kind": "field", "key": "age", "type": { "kind": "num" }, "optional": true } + ] +} +``` + +**Valid JSON:** +```json +{ + "id": "123", + "age": 30 +} +``` + +--- + +#### Object Field (`field`) + +Defines a field within an object schema. + +**Properties:** +- `key` (string): Field name. +- `type` (node): Field value type. +- `optional` (boolean): Field is optional. +- `title`/`intro`/`description`: Document the field. + +**Example:** +```json +{ "kind": "field", "key": "tags", "type": { "kind": "arr", "type": { "kind": "str" } }, "optional": true } +``` + +--- + +#### Map (`map`) + +Represents an object/map with arbitrary string keys and uniform value types. + +**Properties:** +- `type` (node): Value type. +- `validator` (string or array): Custom validation. + +**Example:** +```json +{ "kind": "map", "type": { "kind": "num" } } +``` + +--- + +### 4.3 Advanced Types + +#### Const (`const`) + +Represents a constant value. Useful for enums, discriminants, and singletons. + +**Properties:** +- `value` (any): The constant value. +- `validator` (string or array): Custom validation. + +**Example:** +```json +{ "kind": "const", "value": "success" } +``` + +--- + +#### Reference (`ref`) + +References another named type by its `id`. + +**Properties:** +- `ref` (string): The referenced type’s `id`. + +**Example:** +```json +{ "kind": "ref", "ref": "UserType" } +``` + +--- + +#### Or / Union (`or`) + +A union of multiple possible types, with a discriminant. + +**Properties:** +- `types` (array): Array of possible node types. +- `discriminator` (expression): A JSON Expression which returns the index of the type based on the runtime value. + +**Example:** +```json +{ + "kind": "or", + "types": [ + { + "kind": "obj", + "fields": [ + { "kind": "field", "key": "kind", "type": { "kind": "const", "value": "circle" } }, + { "kind": "field", "key": "radius", "type": { "kind": "num" } } + ] + }, + { + "kind": "obj", + "fields": [ + { "kind": "field", "key": "kind", "type": { "kind": "const", "value": "square" } }, + { "kind": "field", "key": "side", "type": { "kind": "num" } } + ] + } + ], + "discriminator": ['if', ['==', 'circle', ['get', '/kind']], 0, 1], +} +``` + +--- + +## 5. Discriminator and Union Types + +### 5.1 Discriminator + +The **discriminator** tells how to distinguish between the possible types in an `or` union. + +- It is specified as the `discriminator` property of an `or` node. +- The value is typically a property path (e.g., `["kind"]`) or a [JSON Expression](https://jsonjoy.com/specs/json-expression) for advanced cases. +- Each type in the union must be uniquely distinguishable by the discriminator. + +**Example:** +```json +{ + "kind": "or", + "types": [ + { "kind": "obj", "fields": [{ "kind": "field", "key": "tag", "type": { "kind": "const", "value": "a" } }] }, + { "kind": "obj", "fields": [{ "kind": "field", "key": "tag", "type": { "kind": "const", "value": "b" } }] } + ], + "discriminator": ["tag"] +} +``` + +### 5.2 Autodiscriminator + +When all types in a union have a `const` field with the same property name, the discriminator can be **automatically inferred**. This is called the *autodiscriminator*. + +- Tools can infer `"discriminator": ["tag"]` above automatically. +- This reduces errors and boilerplate. + +**Example:** +```json +// Discriminator can be inferred as ["kind"] +{ + "kind": "or", + "types": [ + { "kind": "obj", "fields": [{ "kind": "field", "key": "kind", "type": { "kind": "const", "value": "circle" } }, ...] }, + { "kind": "obj", "fields": [{ "kind": "field", "key": "kind", "type": { "kind": "const", "value": "square" } }, ...] } + ] +} +``` + +- If no explicit `discriminator` is given, tools should attempt autodiscriminant inference. + +**Rules:** +- All variants must have a `const` field with the same property name. +- The values must be unique across variants. + +--- + +## 6. Schema Metadata + +All nodes may contain the following metadata: + +- `meta`: Custom object for code generation, UI, etc. +- `validator`: Name(s) of custom validation rules to apply. +- `examples`: Array of example values (with optional title/intro/description). +- `deprecated`: `{ description?: string }`; marks the type as deprecated. + +**Example:** +```json +{ + "kind": "str", + "id": "userName", + "meta": { "ui:widget": "input" }, + "examples": [ + { "value": "alice", "title": "Typical user name" } + ], + "deprecated": { "description": "Use `displayName` instead" } +} +``` + +--- + +## 7. Validation and Constraints + +### Validators + +- The `validator` property allows referencing standard or custom validators. +- Can be a string (single rule) or an array (multiple). +- Supports standard validators (e.g., `"email"`, `"uuid"`) or custom logic via JSON Expression. + +**Example:** +```json +{ "kind": "str", "validator": "email" } +``` + +### Range and Format Enforcement + +- Use `gte`, `lte`, `min`, `max`, `format` for built-in constraints. +- For additional logic, use `validator`. + +--- + +## 8. Type System Semantics + +- **Type inference:** Types can be mapped to language types using the structure. +- **Optional and required fields:** Fields are required by default; set `optional: true` for optional fields. +- **Type composition:** Types can be nested and composed arbitrarily. +- **Unknown fields:** By default, unknown fields are rejected. Use `unknownFields: true` or `encodeUnknownFields: true` to allow or preserve them. + +--- + +## 9. Comparison with Other Specifications + +### JSON Schema + +- JSON Type is less verbose, more regular, and easier to map to language types. +- No ambiguity between assertion and annotation keywords. +- Union types and discriminants are first-class, not a pattern. + +### JTD (RFC8927) + +- JSON Type is more expressive and extensible. +- Supports metadata, custom validation, union discriminants, and code generation hints. + +--- + +## 10. Serialization and Code Generation + +- JSON Type schemas can be used to generate code for any language. +- Supports optimized (JIT) serializers for JSON, CBOR, MessagePack, etc. +- Can generate random values for testing and fuzzing. +- Enables code generation for API clients, servers, and UI forms. + +--- + +## 11. Registry and Referencing + +- Types with an `id` can be registered and referenced with `ref`. +- Enables modular schemas and reuse across files or services. +- Supports cyclic and recursive types. + +**Example:** +```json +{ "kind": "ref", "ref": "UserType" } +``` + +--- + +## 12. Extensibility and Customization + +- `meta` fields can be used by tools for display, UI, or code generation. +- Custom validators are supported. +- Compatible with documentation generators and code generators. + +--- + +## 13. Security Considerations + +- Always validate JSON Type schemas before use. +- Avoid executing untrusted custom validation code. + +--- + +## 14. Appendix + +### 14.1. Example Schemas + +#### `any` Type + +Represents something of which type is not known. + +Example: +```json +{ + "kind": "any", + "metadata": { + "description": "Any type" + } +} +``` + +#### `bool` Type + +Represents a JSON boolean. + +Example: +```json +{ + "kind": "bool", + "meta": { + "description": "A boolean value" + } +} +``` + +#### `num` Type + +Represents a JSON number. + +Example: +```json +{ + "kind": "num", + "format": "i32", + "gte": 0, + "lte": 100 +} +``` + +#### `str` Type + +Represents a JSON string. + +Example: +```json +{ + "kind": "str", + "format": "utf8", + "min": 1, + "max": 255 +} +``` + +#### `bin` Type + +Represents a binary type. + +Example: +```json +{ + "kind": "bin", + "type": { + "kind": "str" + }, + "format": "json", + "min": 10, + "max": 1024 +} +``` + +#### `arr` Type + +Represents a JSON array. + +Example: +```json +{ + "kind": "arr", + "type": { + "kind": "num" + }, + "min": 1, + "max": 10 +} +``` + +#### `const` Type + +Represents a constant value. + +Example: +```json +{ + "kind": "const", + "value": 42 +} +``` + +#### `obj` Type + +Represents a JSON object type. + +Example: +```json +{ + "kind": "obj", + "fields": [ + { + "kind": "field", + "key": "name", + "type": { + "kind": "str" + }, + "optional": false + }, + { + "kind": "field", + "key": "age", + "type": { + "kind": "num", + "gte": 0 + }, + "optional": true + } + ], + "unknownFields": false +} +``` + + +#### User Schema + +```json +{ + "kind": "obj", + "id": "User", + "fields": [ + { "kind": "field", "key": "id", "type": { "kind": "str" } }, + { "kind": "field", "key": "name", "type": { "kind": "str" } }, + { "kind": "field", "key": "email", "type": { "kind": "str", "validator": "email" } }, + { "kind": "field", "key": "roles", "type": { "kind": "arr", "type": { "kind": "str" } } } + ] +} +``` + +#### Tagged Union Example + +```json +{ + "kind": "or", + "types": [ + { + "kind": "obj", + "fields": [ + { "kind": "field", "key": "type", "type": { "kind": "const", "value": "user" } }, + { "kind": "field", "key": "id", "type": { "kind": "str" } } + ] + }, + { + "kind": "obj", + "fields": [ + { "kind": "field", "key": "type", "type": { "kind": "const", "value": "admin" } }, + { "kind": "field", "key": "level", "type": { "kind": "num" } } + ] + } + ], + "discriminator": ["type"] +} +``` + +--- + +### 14.2. References + +- [JSON Schema](https://json-schema.org/) +- [JSON Type Definition (RFC8927)](https://www.rfc-editor.org/rfc/rfc8927) +- [json-type library](https://github.com/jsonjoy-com/json-type) +- [JSON Expression language](https://jsonjoy.com/specs/json-expression) + +--- + +### 14.3. Further Reading and Tools + +- [json-type Playground](https://jsonjoy.com/type) +- [json-type npm package](https://www.npmjs.com/package/@jsonjoy.com/json-type) +- [JSON Expression language](https://jsonjoy.com/specs/json-expression) diff --git a/src/index.ts b/src/index.ts index 5f751aef..df9e6b04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,3 +52,4 @@ export * from './constants'; export * from './schema'; export * from './type'; export * from './system'; +export * from './value'; diff --git a/src/random/generators.ts b/src/random/generators.ts index 6a919fa2..f6b7f824 100644 --- a/src/random/generators.ts +++ b/src/random/generators.ts @@ -65,10 +65,14 @@ export const num = (type: NumberType): number => { let min = Number.MIN_SAFE_INTEGER; let max = Number.MAX_SAFE_INTEGER; const schema = type.getSchema(); - if (schema.gt !== undefined) min = schema.gt; - if (schema.gte !== undefined) min = schema.gte + 0.000000000000001; - if (schema.lt !== undefined) max = schema.lt; - if (schema.lte !== undefined) max = schema.lte - 0.000000000000001; + const {lt, lte, gt, gte} = schema; + if (gt !== undefined) min = gt; + if (gte !== undefined) + if (gte === lte) return gte; + else min = gte + 0.000000000000001; + if (lt !== undefined) max = lt; + if (lte !== undefined) max = lte - 0.000000000000001; + if (min >= max) return max; if (schema.format) { switch (schema.format) { case 'i8': diff --git a/src/schema/SchemaBuilder.ts b/src/schema/SchemaBuilder.ts index eb443192..a9a82292 100644 --- a/src/schema/SchemaBuilder.ts +++ b/src/schema/SchemaBuilder.ts @@ -19,6 +19,7 @@ import type { FunctionSchema, FunctionStreamingSchema, TType, + Narrow, } from '.'; export class SchemaBuilder { @@ -127,7 +128,7 @@ export class SchemaBuilder { * ``` */ public Const( - value: V, + value: Narrow, options?: Optional>, ): ConstSchema< string extends V ? never : number extends V ? never : boolean extends V ? never : any[] extends V ? never : V diff --git a/src/schema/__tests__/SchemaBuilder.spec.ts b/src/schema/__tests__/SchemaBuilder.spec.ts index d657f9e1..936085ad 100644 --- a/src/schema/__tests__/SchemaBuilder.spec.ts +++ b/src/schema/__tests__/SchemaBuilder.spec.ts @@ -1,4 +1,4 @@ -import {s} from '..'; +import {type ConstSchema, s} from '..'; describe('string', () => { test('can create a string type', () => { @@ -79,3 +79,14 @@ describe('or', () => { }); }); }); + +describe('const', () => { + test('can create an "const" type', () => { + const type = s.Const('Hello'); + const type2: ConstSchema<'Hello'> = type; + expect(type2).toEqual({ + kind: 'const', + value: 'Hello', + }); + }); +}); diff --git a/src/schema/index.ts b/src/schema/index.ts index 5092fba9..07e6cba5 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -1,9 +1,14 @@ +import type {TypeOf} from './schema'; import {SchemaBuilder} from './SchemaBuilder'; export * from './common'; export * from './schema'; /** - * JSON Type AST builder. + * JSON Type default AST builder. */ export const s = new SchemaBuilder(); + +export namespace s { + export type infer = TypeOf; +} diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 4288e381..e26c00ad 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -16,6 +16,13 @@ export interface TType extends Display, Partial { */ meta?: Record; + /** + * Default value for this type. This may be used when the value is not provided + * during validation or serialization. The default value should match the + * type of this schema node. + */ + default?: Value; + /** * List of example usages of this type. */ @@ -52,6 +59,17 @@ export interface WithValidator { /** * Represents something of which type is not known. + * + * Example: + * + * ```json + * { + * "kind": "any", + * "metadata": { + * "description": "Any type" + * } + * } + * ``` */ export interface AnySchema extends TType, WithValidator { kind: 'any'; @@ -65,6 +83,17 @@ export interface AnySchema extends TType, WithValidator { /** * Represents a JSON boolean. + * + * Example: + * + * ```json + * { + * "kind": "bool", + * "meta": { + * "description": "A boolean value" + * } + * } + * ``` */ export interface BooleanSchema extends TType, WithValidator { kind: 'bool'; @@ -72,6 +101,17 @@ export interface BooleanSchema extends TType, WithValidator { /** * Represents a JSON number. + * + * Example: + * + * ```json + * { + * "kind": "num", + * "format": "i32", + * "gte": 0, + * "lte": 100 + * } + * ``` */ export interface NumberSchema extends TType, WithValidator { kind: 'num'; @@ -112,6 +152,17 @@ export interface NumberSchema extends TType, WithValidator { /** * Represents a JSON string. + * + * Example: + * + * ```json + * { + * "kind": "str", + * "format": "utf8", + * "min": 1, + * "max": 255 + * } + * ``` */ export interface StringSchema extends TType, WithValidator { kind: 'str'; @@ -150,6 +201,20 @@ export interface StringSchema extends TType, WithValidator { /** * Represents a binary type. + * + * Example: + * + * ```json + * { + * "kind": "bin", + * "type": { + * "kind": "str" + * }, + * "format": "json", + * "min": 10, + * "max": 1024 + * } + * ``` */ export interface BinarySchema extends TType, WithValidator { kind: 'bin'; @@ -169,6 +234,19 @@ export interface BinarySchema extends TType, WithValidato /** * Represents a JSON array. + * + * Example: + * + * ```json + * { + * "kind": "arr", + * "type": { + * "kind": "num" + * }, + * "min": 1, + * "max": 10 + * } + * ``` */ export interface ArraySchema extends TType>, WithValidator { kind: 'arr'; @@ -182,6 +260,13 @@ export interface ArraySchema extends TType /** * Represents a constant value. + * Example: + * ```json + * { + * "kind": "const", + * "value": 42 + * } + * ``` */ export interface ConstSchema extends TType, WithValidator { /** @todo Rename to "con". */ @@ -202,6 +287,32 @@ export interface TupleSchema extends TType, WithValidat /** * Represents a JSON object type, the "object" type excluding "null" in JavaScript, * the "object" type in JSON Schema, and the "obj" type in MessagePack. + * Example: + * ```json + * { + * "kind": "obj", + * "fields": [ + * { + * "kind": "field", + * "key": "name", + * "type": { + * "kind": "str" + * }, + * "optional": false + * }, + * { + * "kind": "field", + * "key": "age", + * "type": { + * "kind": "num", + * "gte": 0 + * }, + * "optional": true + * } + * ], + * "unknownFields": false + * } + * ``` */ export interface ObjectSchema< Fields extends ObjectFieldSchema[] | readonly ObjectFieldSchema[] = any, @@ -246,8 +357,14 @@ export interface ObjectFieldSchema extends TType>, WithValidator { kind: 'map'; - /** Type of all values in the map. */ + /** + * Type of all values in the map. + * + * @todo Rename to `val`. And add `key` field for the key type. Make `key` default to `str`. + */ type: T; } @@ -299,6 +420,7 @@ export interface FunctionSchema = (req: Observable, ctx?: Ctx) => Observable; export interface FunctionStreamingSchema extends TType { + /** @todo Rename to `fn`. Make it a property on the schema instead. */ kind: 'fn$'; req: Req; res: Res; @@ -396,3 +518,8 @@ export type OptionalProps = Exclude< export type Optional = Pick>; export type Required = Omit>; + +export type Narrow = + | (T extends infer U ? U : never) + | Extract + | ([T] extends [[]] ? [] : {[K in keyof T]: Narrow}); diff --git a/src/system/types.ts b/src/system/types.ts index 5a3ee145..db86721e 100644 --- a/src/system/types.ts +++ b/src/system/types.ts @@ -23,3 +23,5 @@ export type ResolveType = T extends TypeAlias : T extends Schema ? TypeOf : never; + +export type infer = ResolveType; diff --git a/src/type/TypeBuilder.ts b/src/type/TypeBuilder.ts index 74885ae4..0935f38f 100644 --- a/src/type/TypeBuilder.ts +++ b/src/type/TypeBuilder.ts @@ -7,9 +7,28 @@ import type {TypeOfAlias} from '../system/types'; const {s} = schema; +type UnionToIntersection = (U extends never ? never : (arg: U) => never) extends (arg: infer I) => void ? I : never; + +type UnionToTuple = UnionToIntersection T> extends (_: never) => infer W + ? [...UnionToTuple>, W] + : []; + +type ObjValueTuple, R extends any[] = []> = KS extends [ + infer K, + ...infer KT, +] + ? ObjValueTuple + : R; + +type RecordToFields> = ObjValueTuple<{ + [K in keyof O]: classes.ObjectFieldType; +}>; + export class TypeBuilder { constructor(public system?: TypeSystem) {} + // -------------------------------------------------------------- empty types + get any() { return this.Any(); } @@ -51,20 +70,96 @@ export class TypeBuilder { } get fn() { - return this.Function(this.any, this.any); + return this.Function(this.undef, this.undef); } get fn$() { - return this.Function$(this.any, this.any); + return this.Function$(this.undef, this.undef); } + // --------------------------------------------------------------- shorthands + + public readonly undefined = () => this.undef; + public readonly null = () => this.nil; + public readonly boolean = () => this.bool; + public readonly number = () => this.num; + public readonly string = () => this.str; + public readonly binary = () => this.bin; + + public readonly con = (value: schema.Narrow, options?: schema.Optional) => + this.Const(value, options); + public readonly literal = this.con; + + public readonly array = (type?: T, options?: schema.Optional) => + this.Array( + (type ?? this.any) as T extends Type ? T : classes.AnyType, + options, + ); + + public readonly tuple = (...types: F) => this.Tuple(...types); + + /** + * Creates an object type with the specified properties. This is a shorthand for + * `t.Object(t.prop(key, value), ...)`. + * + * Importantly, this method does not allow to specify object field order, + * so the order of properties in the resulting type is not guaranteed. + * + * Example: + * + * ```ts + * t.object({ + * id: t.str, + * name: t.string(), + * age: t.num, + * verified: t.bool, + * }); + * ``` + * + * @param record A mapping of property names to types. + * @returns An object type. + */ + public readonly object = >(record: R): classes.ObjectType> => { + const fields: classes.ObjectFieldType[] = []; + for (const [key, value] of Object.entries(record)) fields.push(this.prop(key, value)); + const obj = new classes.ObjectType>(fields as any); + obj.system = this.system; + return obj; + }; + + /** + * Creates a type that represents a value that may be present or absent. The + * value is `undefined` if absent. This is a shorthand for `t.Or(type, t.undef)`. + */ + public readonly maybe = (type: T) => this.Or(type, this.undef); + + /** + * Creates a union type from a list of values. This is a shorthand for + * `t.Or(t.Const(value1), t.Const(value2), ...)`. For example, the below + * are equivalent: + * + * ```ts + * t.enum('red', 'green', 'blue'); + * t.Or(t.Const('red'), t.Const('green'), t.Const('blue')); + * ``` + * + * @param values The values to include in the union. + * @returns A union type representing the values. + */ + public readonly enum = ( + ...values: T + ): classes.OrType<{[K in keyof T]: classes.ConstType>}> => + this.Or(...values.map((type) => this.Const(type as any))) as any; + + // --------------------------------------------------- base node constructors + public Any(options?: schema.Optional) { const type = new classes.AnyType(s.Any(options)); type.system = this.system; return type; } - public Const(value: V, options?: schema.Optional) { + public Const(value: schema.Narrow, options?: schema.Optional) { type V2 = string extends V ? never : number extends V @@ -133,8 +228,8 @@ export class TypeBuilder { return field; } - public Map(type: T, options?: schema.Optional) { - const map = new classes.MapType(type, options); + public Map(val: T, options?: schema.Optional) { + const map = new classes.MapType(val, options); map.system = this.system; return map; } @@ -151,15 +246,22 @@ export class TypeBuilder { return type; } - /** @todo Shorten to `Func`. */ - public Function(req: Req, res: Res) { - const fn = new classes.FunctionType(req, res); + public Function( + req: Req, + res: Res, + options?: schema.Optional, + ) { + const fn = new classes.FunctionType(req, res, options); fn.system = this.system; return fn; } - public Function$(req: Req, res: Res) { - const fn = new classes.FunctionStreamingType(req, res); + public Function$( + req: Req, + res: Res, + options?: schema.Optional, + ) { + const fn = new classes.FunctionStreamingType(req, res, options); fn.system = this.system; return fn; } diff --git a/src/type/__tests__/SchemaOf.spec.ts b/src/type/__tests__/SchemaOf.spec.ts index 30195649..e6c435a8 100644 --- a/src/type/__tests__/SchemaOf.spec.ts +++ b/src/type/__tests__/SchemaOf.spec.ts @@ -1,6 +1,7 @@ import {EMPTY} from 'rxjs'; import {type SchemaOf, t} from '..'; import type {TypeOf} from '../../schema'; +import type * as system from '../../system'; test('const', () => { const type = t.Const(42); @@ -95,16 +96,56 @@ test('or', () => { const v2: T = 'abc'; }); -test('fn', () => { - const type = t.Function(t.num, t.str); - type S = SchemaOf; - type T = TypeOf; - const v: T = async (arg: number) => 'abc'; +describe('fn', () => { + test('fn', () => { + const type = t.Function(t.num, t.str); + type S = SchemaOf; + type T = TypeOf; + const v: T = async (arg: number) => 'abc'; + }); + + test('no input and no output', () => { + const type = t.Function(t.undef, t.undef); + type S = SchemaOf; + type T = TypeOf; + const v: T = async () => {}; + }); + + test('fn$', () => { + const type = t.Function$(t.num, t.str); + type S = SchemaOf; + type T = TypeOf; + const v: T = (arg) => EMPTY; + }); }); -test('fn$', () => { - const type = t.Function$(t.num, t.str); - type S = SchemaOf; - type T = TypeOf; - const v: T = (arg) => EMPTY; +test('string patch', () => { + const StringOperationInsert = t.Tuple(t.Const(1), t.str).options({ + title: 'Insert String', + description: 'Inserts a string at the current position in the source string.', + }); + const StringOperationEqual = t.Tuple(t.Const(0), t.str).options({ + title: 'Equal String', + description: 'Keeps the current position in the source string unchanged.', + }); + const StringOperationDelete = t.Tuple(t.Const(-1), t.str).options({ + title: 'Delete String', + description: 'Deletes the current position in the source string.', + }); + const StringPatch = t.Array(t.Or(StringOperationInsert, StringOperationEqual, StringOperationDelete)).options({ + title: 'String Patch', + description: + 'A list of string operations that can be applied to a source string to produce a destination string, or vice versa.', + }); + + type T = system.infer; + const v: T = [ + [1, 'Hello'], + [0, 'World'], + [-1, '!'], + ]; + const v2: T = [ + // @ts-expect-error + [2, 'Test'], + ]; }); diff --git a/src/type/__tests__/TypeBuilder.spec.ts b/src/type/__tests__/TypeBuilder.spec.ts index 70dbd589..ddd961eb 100644 --- a/src/type/__tests__/TypeBuilder.spec.ts +++ b/src/type/__tests__/TypeBuilder.spec.ts @@ -1,6 +1,6 @@ +import {NumberType, ObjectFieldType, ObjectType, StringType} from '../classes'; import {type SchemaOf, t} from '..'; import type {TypeOf} from '../../schema'; -import {NumberType, ObjectFieldType, ObjectType, StringType} from '../classes'; test('number', () => { const type = t.Number({ @@ -14,6 +14,20 @@ test('number', () => { }); }); +describe('"fn" kind', () => { + test('can use shorthand to define function', () => { + const type1 = t.fn.title('My Function').inp(t.str).out(t.num); + const type2 = t.Function(t.str, t.num, {title: 'My Function'}); + expect(type1.getSchema()).toEqual(type2.getSchema()); + }); + + test('can use shorthand to define a streaming function', () => { + const type1 = t.fn$.title('My Function').inp(t.str).out(t.num); + const type2 = t.Function$(t.str, t.num, {title: 'My Function'}); + expect(type1.getSchema()).toEqual(type2.getSchema()); + }); +}); + test('can construct a array type', () => { const type = t.Array(t.Or(t.num, t.str.options({title: 'Just a string'}))); expect(type.getSchema()).toStrictEqual({ @@ -61,6 +75,50 @@ test('can construct a realistic object', () => { }; }); +test('can build type using lowercase shortcuts', () => { + const MyObject = t + .object({ + type: t.con('user'), + id: t.string(), + name: t.string(), + age: t.number(), + coordinates: t.tuple(t.number(), t.number()), + verified: t.boolean(), + offsets: t.array(t.number()), + enum: t.enum(1, 2, 'three'), + optional: t.maybe(t.string()), + }) + .opt('description', t.string()); + // console.log(MyObject + ''); + const MyObject2 = t.obj + .prop('type', t.Const('user')) + .prop('id', t.str) + .prop('name', t.str) + .prop('age', t.num) + .prop('coordinates', t.Tuple(t.num, t.num)) + .prop('verified', t.bool) + .prop('offsets', t.array(t.num)) + .prop('enum', t.Or(t.Const(1), t.Const(2), t.Const('three'))) + .prop('optional', t.Or(t.str, t.undef)) + .opt('description', t.str); + expect(MyObject.getSchema()).toEqual(MyObject2.getSchema()); + type ObjectType = t.infer; + type ObjectType2 = t.infer; + const obj: ObjectType = { + type: 'user', + id: '123', + name: 'Test', + coordinates: [1.23, 4.56], + age: 30, + verified: true, + offsets: [1, 2, 3], + enum: 'three', + optional: undefined, + } as ObjectType2; + MyObject.validate(obj); + MyObject2.validate(obj); +}); + describe('import()', () => { test('can import a number schema', () => { const type = t.import({ diff --git a/src/type/__tests__/__snapshots__/toString.spec.ts.snap b/src/type/__tests__/__snapshots__/toString.spec.ts.snap index 2ddf6e95..5115e79e 100644 --- a/src/type/__tests__/__snapshots__/toString.spec.ts.snap +++ b/src/type/__tests__/__snapshots__/toString.spec.ts.snap @@ -56,12 +56,12 @@ exports[`can print a type 1`] = ` │ └─ num ├─ "simpleFn1": │ └─ fn -│ ├─ req: any -│ └─ res: any +│ ├─ req: const → undefined +│ └─ res: const → undefined ├─ "simpleFn2": │ └─ fn$ -│ ├─ req: any -│ └─ res: any +│ ├─ req: const → undefined +│ └─ res: const → undefined └─ "function": └─ fn ├─ req: obj diff --git a/src/type/__tests__/validateTestSuite.ts b/src/type/__tests__/validateTestSuite.ts index 7af9a009..c739c36c 100644 --- a/src/type/__tests__/validateTestSuite.ts +++ b/src/type/__tests__/validateTestSuite.ts @@ -47,6 +47,13 @@ export const validateTestSuite = (validate: (type: Type, value: unknown) => void validate(obj, {foo: 'bar'}); expect(() => validate(obj, {foo: 'bar', baz: 'bar'})).toThrowErrorMatchingInlineSnapshot(`"CONST"`); }); + + test('empty value', () => { + validate(t.undef, undefined); + expect(() => validate(t.undef, {})).toThrow(); + expect(() => validate(t.undef, null)).toThrow(); + expect(() => validate(t.undef, 123)).toThrow(); + }); }); describe('undefined', () => { diff --git a/src/type/classes/AbstractType.ts b/src/type/classes/AbstractType.ts index b6a12e2b..693b0ce2 100644 --- a/src/type/classes/AbstractType.ts +++ b/src/type/classes/AbstractType.ts @@ -36,6 +36,7 @@ import { type CompiledCapacityEstimator, } from '../../codegen/capacity/CapacityEstimatorCodegenContext'; import {generate} from '../../codegen/capacity/estimators'; +import type {TExample} from '../../schema'; import type {JsonValueCodec} from '@jsonjoy.com/json-pack/lib/codecs/types'; import type * as jsonSchema from '../../json-schema'; import type {BaseType} from '../types'; @@ -91,6 +92,38 @@ export abstract class AbstractType implements BaseType< return this; } + public title(title: string): this { + this.schema.title = title; + return this; + } + + public intro(intro: string): this { + this.schema.intro = intro; + return this; + } + + public description(description: string): this { + this.schema.description = description; + return this; + } + + public default(value: schema.Schema['default']): this { + this.schema.default = value; + return this; + } + + public example( + value: schema.TypeOf, + title?: TExample['title'], + options?: Omit, + ): this { + const examples = (this.schema.examples ??= []); + const example: TExample = {...options, value}; + if (typeof title === 'string') example.title = title; + examples.push(example); + return this; + } + public getOptions(): schema.Optional { const {kind, ...options} = this.schema; return options as any; diff --git a/src/type/classes/ArrayType.ts b/src/type/classes/ArrayType.ts index a1296d20..75eddc75 100644 --- a/src/type/classes/ArrayType.ts +++ b/src/type/classes/ArrayType.ts @@ -1,28 +1,21 @@ +import * as schema from '../../schema'; +import {ValidationError} from '../../constants'; +import {MessagePackEncoderCodegenContext} from '../../codegen/binary/MessagePackEncoderCodegenContext'; +import {AbstractType} from './AbstractType'; +import {CborEncoderCodegenContext} from '../../codegen/binary/CborEncoderCodegenContext'; import {JsExpression} from '@jsonjoy.com/util/lib/codegen/util/JsExpression'; +import {printTree} from 'tree-dump'; import type {BinaryJsonEncoder} from '@jsonjoy.com/json-pack/lib/types'; -import {printTree} from 'tree-dump/lib/printTree'; -import * as schema from '../../schema'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; -import {ValidationError} from '../../constants'; import type {JsonTextEncoderCodegenContext} from '../../codegen/json/JsonTextEncoderCodegenContext'; -import {CborEncoderCodegenContext} from '../../codegen/binary/CborEncoderCodegenContext'; import type {JsonEncoderCodegenContext} from '../../codegen/binary/JsonEncoderCodegenContext'; import type {BinaryEncoderCodegenContext} from '../../codegen/binary/BinaryEncoderCodegenContext'; -import {MessagePackEncoderCodegenContext} from '../../codegen/binary/MessagePackEncoderCodegenContext'; -import {AbstractType} from './AbstractType'; -import type * as jsonSchema from '../../json-schema'; import type {SchemaOf, Type} from '../types'; import type {TypeSystem} from '../../system/TypeSystem'; import type {json_string} from '@jsonjoy.com/util/lib/json-brand'; import type * as ts from '../../typescript/types'; import type {TypeExportContext} from '../../system/TypeExportContext'; -import type {CapacityEstimatorCodegenContext} from '../../codegen/capacity/CapacityEstimatorCodegenContext'; -import {ConstType} from './ConstType'; -import {BooleanType} from './BooleanType'; -import {NumberType} from './NumberType'; -import {MaxEncodingOverhead} from '@jsonjoy.com/util/lib/json-size'; -import type * as jtd from '../../jtd/types'; export class ArrayType extends AbstractType>> { protected schema: schema.ArraySchema; @@ -35,6 +28,16 @@ export class ArrayType extends AbstractType> { return { ...this.schema, diff --git a/src/type/classes/BinaryType.ts b/src/type/classes/BinaryType.ts index cc6797d2..8e1f27a6 100644 --- a/src/type/classes/BinaryType.ts +++ b/src/type/classes/BinaryType.ts @@ -1,37 +1,21 @@ -import type {JsExpression} from '@jsonjoy.com/util/lib/codegen/util/JsExpression'; -import type {BinaryJsonEncoder} from '@jsonjoy.com/json-pack/lib/types'; import {printTree} from 'tree-dump/lib/printTree'; import * as schema from '../../schema'; -import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; import {stringifyBinary} from '@jsonjoy.com/json-pack/lib/json-binary'; +import {AbstractType} from './AbstractType'; +import {ValidationError} from '../../constants'; +import type {JsExpression} from '@jsonjoy.com/util/lib/codegen/util/JsExpression'; +import type {BinaryJsonEncoder} from '@jsonjoy.com/json-pack/lib/types'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; -import {ValidationError} from '../../constants'; import type {JsonTextEncoderCodegenContext} from '../../codegen/json/JsonTextEncoderCodegenContext'; import type {CborEncoderCodegenContext} from '../../codegen/binary/CborEncoderCodegenContext'; import type {JsonEncoderCodegenContext} from '../../codegen/binary/JsonEncoderCodegenContext'; import type {BinaryEncoderCodegenContext} from '../../codegen/binary/BinaryEncoderCodegenContext'; import type {MessagePackEncoderCodegenContext} from '../../codegen/binary/MessagePackEncoderCodegenContext'; -import type {CapacityEstimatorCodegenContext} from '../../codegen/capacity/CapacityEstimatorCodegenContext'; -import {MaxEncodingOverhead} from '@jsonjoy.com/util/lib/json-size'; -import {AbstractType} from './AbstractType'; -import type * as jsonSchema from '../../json-schema'; import type {SchemaOf, Type} from '../types'; import type {TypeSystem} from '../../system/TypeSystem'; import type {json_string} from '@jsonjoy.com/util/lib/json-brand'; import type * as ts from '../../typescript/types'; -import type {TypeExportContext} from '../../system/TypeExportContext'; - -const formats = new Set([ - 'bencode', - 'bson', - 'cbor', - 'ion', - 'json', - 'msgpack', - 'resp3', - 'ubjson', -]); export class BinaryType extends AbstractType { protected schema: schema.BinarySchema; @@ -44,6 +28,21 @@ export class BinaryType extends AbstractType> { return { ...this.schema, diff --git a/src/type/classes/ConstType.ts b/src/type/classes/ConstType.ts index 47c17dab..91c1dc11 100644 --- a/src/type/classes/ConstType.ts +++ b/src/type/classes/ConstType.ts @@ -1,6 +1,4 @@ -import {cloneBinary} from '@jsonjoy.com/util/lib/json-clone'; import {ValidationError} from '../../constants'; -import {maxEncodingCapacity} from '@jsonjoy.com/util/lib/json-size'; import {AbstractType} from './AbstractType'; import {deepEqualCodegen} from '@jsonjoy.com/util/lib/json-equal/deepEqualCodegen'; import type * as schema from '../../schema'; @@ -13,13 +11,9 @@ import type {BinaryEncoderCodegenContext} from '../../codegen/binary/BinaryEncod import type {JsExpression} from '@jsonjoy.com/util/lib/codegen/util/JsExpression'; import type {MessagePackEncoderCodegenContext} from '../../codegen/binary/MessagePackEncoderCodegenContext'; import type {BinaryJsonEncoder} from '@jsonjoy.com/json-pack/lib/types'; -import type {CapacityEstimatorCodegenContext} from '../../codegen/capacity/CapacityEstimatorCodegenContext'; -import type * as jsonSchema from '../../json-schema'; import type {TypeSystem} from '../../system/TypeSystem'; import type {json_string} from '@jsonjoy.com/util/lib/json-brand'; import type * as ts from '../../typescript/types'; -import type {TypeExportContext} from '../../system/TypeExportContext'; -import type * as jtd from '../../jtd/types'; export class ConstType extends AbstractType> { private __json: json_string; diff --git a/src/type/classes/FunctionType.ts b/src/type/classes/FunctionType.ts index c207f6bd..1684e3da 100644 --- a/src/type/classes/FunctionType.ts +++ b/src/type/classes/FunctionType.ts @@ -41,6 +41,24 @@ export class FunctionType extends AbstractTy } as any; } + public request(req: T): FunctionType { + (this as any).req = req; + return this as any; + } + + public inp(req: T): FunctionType { + return this.request(req); + } + + public response(res: T): FunctionType { + (this as any).res = res; + return this as any; + } + + public out(res: T): FunctionType { + return this.response(res); + } + public getSchema(): schema.FunctionSchema, SchemaOf> { return { ...this.schema, @@ -84,6 +102,24 @@ export class FunctionStreamingType extends A } as any; } + public request(req: T): FunctionType { + (this as any).req = req; + return this as any; + } + + public inp(req: T): FunctionType { + return this.request(req); + } + + public response(res: T): FunctionType { + (this as any).res = res; + return this as any; + } + + public out(res: T): FunctionType { + return this.response(res); + } + public getSchema(): schema.FunctionStreamingSchema, SchemaOf> { return { ...this.schema, diff --git a/src/type/classes/MapType.ts b/src/type/classes/MapType.ts index ea89bea2..8b03e5c5 100644 --- a/src/type/classes/MapType.ts +++ b/src/type/classes/MapType.ts @@ -1,21 +1,17 @@ import {JsExpression} from '@jsonjoy.com/util/lib/codegen/util/JsExpression'; -import type {BinaryJsonEncoder} from '@jsonjoy.com/json-pack/lib/types'; import {asString} from '@jsonjoy.com/util/lib/strings/asString'; import {printTree} from 'tree-dump/lib/printTree'; import * as schema from '../../schema'; -import {RandomJson} from '@jsonjoy.com/util/lib/json-random'; -import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; -import type {ValidationPath} from '../../codegen/validator/types'; import {ValidationError} from '../../constants'; -import type {JsonTextEncoderCodegenContext} from '../../codegen/json/JsonTextEncoderCodegenContext'; import {CborEncoderCodegenContext} from '../../codegen/binary/CborEncoderCodegenContext'; -import type {JsonEncoderCodegenContext} from '../../codegen/binary/JsonEncoderCodegenContext'; -import type {BinaryEncoderCodegenContext} from '../../codegen/binary/BinaryEncoderCodegenContext'; import {MessagePackEncoderCodegenContext} from '../../codegen/binary/MessagePackEncoderCodegenContext'; -import type {CapacityEstimatorCodegenContext} from '../../codegen/capacity/CapacityEstimatorCodegenContext'; -import {MaxEncodingOverhead} from '@jsonjoy.com/util/lib/json-size'; import {AbstractType} from './AbstractType'; -import type * as jsonSchema from '../../json-schema'; +import type {BinaryJsonEncoder} from '@jsonjoy.com/json-pack/lib/types'; +import type {JsonEncoderCodegenContext} from '../../codegen/binary/JsonEncoderCodegenContext'; +import type {BinaryEncoderCodegenContext} from '../../codegen/binary/BinaryEncoderCodegenContext'; +import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; +import type {ValidationPath} from '../../codegen/validator/types'; +import type {JsonTextEncoderCodegenContext} from '../../codegen/json/JsonTextEncoderCodegenContext'; import type {SchemaOf, Type} from '../types'; import type {TypeSystem} from '../../system/TypeSystem'; import type {json_string} from '@jsonjoy.com/util/lib/json-brand'; diff --git a/src/type/classes/NumberType.ts b/src/type/classes/NumberType.ts index dcf42502..a6784bf6 100644 --- a/src/type/classes/NumberType.ts +++ b/src/type/classes/NumberType.ts @@ -1,8 +1,8 @@ -import type * as schema from '../../schema'; import {floats, ints, uints} from '../../util'; +import {ValidationError} from '../../constants'; +import {AbstractType} from './AbstractType'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; -import {ValidationError} from '../../constants'; import type {JsonTextEncoderCodegenContext} from '../../codegen/json/JsonTextEncoderCodegenContext'; import type {CborEncoderCodegenContext} from '../../codegen/binary/CborEncoderCodegenContext'; import type {JsonEncoderCodegenContext} from '../../codegen/binary/JsonEncoderCodegenContext'; @@ -10,14 +10,10 @@ import type {BinaryEncoderCodegenContext} from '../../codegen/binary/BinaryEncod import type {JsExpression} from '@jsonjoy.com/util/lib/codegen/util/JsExpression'; import type {MessagePackEncoderCodegenContext} from '../../codegen/binary/MessagePackEncoderCodegenContext'; import type {BinaryJsonEncoder} from '@jsonjoy.com/json-pack/lib/types'; -import type {CapacityEstimatorCodegenContext} from '../../codegen/capacity/CapacityEstimatorCodegenContext'; -import {MaxEncodingOverhead} from '@jsonjoy.com/util/lib/json-size'; -import {AbstractType} from './AbstractType'; -import type * as jsonSchema from '../../json-schema'; import type {TypeSystem} from '../../system/TypeSystem'; import type {json_string} from '@jsonjoy.com/util/lib/json-brand'; import type * as ts from '../../typescript/types'; -import type {TypeExportContext} from '../../system/TypeExportContext'; +import type * as schema from '../../schema'; import type * as jtd from '../../jtd/types'; export class NumberType extends AbstractType { @@ -25,6 +21,31 @@ export class NumberType extends AbstractType { super(); } + public format(format: schema.NumberSchema['format']): this { + this.schema.format = format; + return this; + } + + public gt(gt: schema.NumberSchema['gt']): this { + this.schema.gt = gt; + return this; + } + + public gte(gte: schema.NumberSchema['gte']): this { + this.schema.gte = gte; + return this; + } + + public lt(lt: schema.NumberSchema['lt']): this { + this.schema.lt = lt; + return this; + } + + public lte(lte: schema.NumberSchema['lte']): this { + this.schema.lte = lte; + return this; + } + public codegenValidator(ctx: ValidatorCodegenContext, path: ValidationPath, r: string): void { const {format, gt, gte, lt, lte} = this.schema; if (format && ints.has(format)) { diff --git a/src/type/classes/ObjectType.ts b/src/type/classes/ObjectType.ts index bbcdc03b..5c042db8 100644 --- a/src/type/classes/ObjectType.ts +++ b/src/type/classes/ObjectType.ts @@ -94,6 +94,47 @@ export class ObjectType[] = ObjectFieldType< super(); } + private _field( + field: ObjectFieldType, + options?: schema.Optional>, + ): void { + if (options) field.options(options); + field.system = this.system; + this.fields.push(field as any); + } + + /** + * Adds a property to the object type. + * @param key The key of the property. + * @param value The value type of the property. + * @param options Optional schema options for the property. + * @returns A new object type with the added property. + */ + public prop( + key: K, + value: V, + options?: schema.Optional>>, + ): ObjectType<[...F, ObjectFieldType]> { + this._field(new ObjectFieldType(key, value), options); + return this; + } + + /** + * Adds an optional property to the object type. + * @param key The key of the property. + * @param value The value type of the property. + * @param options Optional schema options for the property. + * @returns A new object type with the added property. + */ + public opt( + key: K, + value: V, + options?: schema.Optional>>, + ): ObjectType<[...F, ObjectOptionalFieldType]> { + this._field(new ObjectOptionalFieldType(key, value), options); + return this; + } + public getSchema(): schema.ObjectSchema> { return { ...this.schema, @@ -169,7 +210,7 @@ export class ObjectType[] = ObjectFieldType< if (!canSkipObjectKeyUndefinedCheck((field.value as AbstractType).getSchema().kind)) { const err = ctx.err(ValidationError.KEY, [...path, field.key]); ctx.js(/* js */ `var ${rv} = ${r}${accessor};`); - ctx.js(/* js */ `if (${rv} === undefined) return ${err};`); + ctx.js(/* js */ `if (!(${JSON.stringify(field.key)} in ${r})) return ${err};`); } field.value.codegenValidator(ctx, keyPath, `${r}${accessor}`); } diff --git a/src/type/classes/StringType.ts b/src/type/classes/StringType.ts index dbcdf467..a225d643 100644 --- a/src/type/classes/StringType.ts +++ b/src/type/classes/StringType.ts @@ -1,8 +1,10 @@ import type * as schema from '../../schema'; import {asString} from '@jsonjoy.com/util/lib/strings/asString'; +import {AbstractType} from './AbstractType'; +import {isAscii, isUtf8} from '../../util/stringFormats'; +import {ValidationError} from '../../constants'; import type {ValidatorCodegenContext} from '../../codegen/validator/ValidatorCodegenContext'; import type {ValidationPath} from '../../codegen/validator/types'; -import {ValidationError} from '../../constants'; import type {JsonTextEncoderCodegenContext} from '../../codegen/json/JsonTextEncoderCodegenContext'; import type {CborEncoderCodegenContext} from '../../codegen/binary/CborEncoderCodegenContext'; import type {JsonEncoderCodegenContext} from '../../codegen/binary/JsonEncoderCodegenContext'; @@ -10,22 +12,31 @@ import type {BinaryEncoderCodegenContext} from '../../codegen/binary/BinaryEncod import type {JsExpression} from '@jsonjoy.com/util/lib/codegen/util/JsExpression'; import type {MessagePackEncoderCodegenContext} from '../../codegen/binary/MessagePackEncoderCodegenContext'; import type {BinaryJsonEncoder} from '@jsonjoy.com/json-pack/lib/types'; -import type {CapacityEstimatorCodegenContext} from '../../codegen/capacity/CapacityEstimatorCodegenContext'; -import {MaxEncodingOverhead} from '@jsonjoy.com/util/lib/json-size'; -import {AbstractType} from './AbstractType'; -import type * as jsonSchema from '../../json-schema'; import type {TypeSystem} from '../../system/TypeSystem'; import type {json_string} from '@jsonjoy.com/util/lib/json-brand'; import type * as ts from '../../typescript/types'; -import type {TypeExportContext} from '../../system/TypeExportContext'; import type * as jtd from '../../jtd/types'; -import {isAscii, isUtf8} from '../../util/stringFormats'; export class StringType extends AbstractType { constructor(protected schema: schema.StringSchema) { super(); } + public format(format: schema.StringSchema['format']): this { + this.schema.format = format; + return this; + } + + public min(min: schema.StringSchema['min']): this { + this.schema.min = min; + return this; + } + + public max(max: schema.StringSchema['max']): this { + this.schema.max = max; + return this; + } + public codegenValidator(ctx: ValidatorCodegenContext, path: ValidationPath, r: string): void { const error = ctx.err(ValidationError.STR, path); ctx.js(/* js */ `if(typeof ${r} !== "string") return ${error};`); diff --git a/src/type/classes/__tests__/BinaryType.spec.ts b/src/type/classes/__tests__/BinaryType.spec.ts new file mode 100644 index 00000000..b6649728 --- /dev/null +++ b/src/type/classes/__tests__/BinaryType.spec.ts @@ -0,0 +1,78 @@ +import {t} from '../../..'; + +test('can use convenience methods to define type schema fields', () => { + const binary = t.bin; + expect(binary.getSchema()).toEqual({kind: 'bin', type: {kind: 'any'}}); + binary.title('My Binary'); + expect(binary.getSchema()).toEqual({kind: 'bin', type: {kind: 'any'}, title: 'My Binary'}); + binary.intro('This is a binary type'); + expect(binary.getSchema()).toEqual({ + kind: 'bin', + type: {kind: 'any'}, + title: 'My Binary', + intro: 'This is a binary type', + }); + binary.description('A detailed description of the binary type'); + expect(binary.getSchema()).toEqual({ + kind: 'bin', + type: {kind: 'any'}, + title: 'My Binary', + intro: 'This is a binary type', + description: 'A detailed description of the binary type', + }); + binary.format('json'); + expect(binary.getSchema()).toEqual({ + kind: 'bin', + type: {kind: 'any'}, + title: 'My Binary', + intro: 'This is a binary type', + description: 'A detailed description of the binary type', + format: 'json', + }); + binary.min(5); + expect(binary.getSchema()).toEqual({ + kind: 'bin', + type: {kind: 'any'}, + title: 'My Binary', + intro: 'This is a binary type', + description: 'A detailed description of the binary type', + format: 'json', + min: 5, + }); + binary.max(10); + expect(binary.getSchema()).toEqual({ + kind: 'bin', + type: {kind: 'any'}, + title: 'My Binary', + intro: 'This is a binary type', + description: 'A detailed description of the binary type', + format: 'json', + min: 5, + max: 10, + }); + binary.default(new Uint8Array([1, 2, 3])); + expect(binary.getSchema()).toEqual({ + kind: 'bin', + type: {kind: 'any'}, + title: 'My Binary', + intro: 'This is a binary type', + description: 'A detailed description of the binary type', + format: 'json', + min: 5, + max: 10, + default: new Uint8Array([1, 2, 3]), + }); + binary.example(new Uint8Array([4, 5, 6]), 'Example Binary', {description: 'An example binary value'}); + expect(binary.getSchema()).toEqual({ + kind: 'bin', + type: {kind: 'any'}, + title: 'My Binary', + intro: 'This is a binary type', + description: 'A detailed description of the binary type', + format: 'json', + min: 5, + max: 10, + default: new Uint8Array([1, 2, 3]), + examples: [{value: new Uint8Array([4, 5, 6]), title: 'Example Binary', description: 'An example binary value'}], + }); +}); diff --git a/src/type/classes/__tests__/NumberType.spec.ts b/src/type/classes/__tests__/NumberType.spec.ts new file mode 100644 index 00000000..e4e90ea2 --- /dev/null +++ b/src/type/classes/__tests__/NumberType.spec.ts @@ -0,0 +1,48 @@ +import {t} from '../../..'; + +test('can use convenience methods to define type schema fields', () => { + const number = t.Number(); + expect(number.getSchema()).toEqual({kind: 'num'}); + number.title('My Number'); + expect(number.getSchema()).toEqual({kind: 'num', title: 'My Number'}); + number.intro('This is a number type'); + expect(number.getSchema()).toEqual({ + kind: 'num', + title: 'My Number', + intro: 'This is a number type', + }); + number.description('A detailed description of the number type'); + expect(number.getSchema()).toEqual({ + kind: 'num', + title: 'My Number', + intro: 'This is a number type', + description: 'A detailed description of the number type', + }); + number.gt(5); + expect(number.getSchema()).toEqual({ + kind: 'num', + title: 'My Number', + intro: 'This is a number type', + description: 'A detailed description of the number type', + gt: 5, + }); + number.lte(10); + expect(number.getSchema()).toEqual({ + kind: 'num', + title: 'My Number', + intro: 'This is a number type', + description: 'A detailed description of the number type', + gt: 5, + lte: 10, + }); + number.format('i32'); + expect(number.getSchema()).toEqual({ + kind: 'num', + title: 'My Number', + intro: 'This is a number type', + description: 'A detailed description of the number type', + gt: 5, + lte: 10, + format: 'i32', + }); +}); diff --git a/src/type/classes/__tests__/ObjectType.spec.ts b/src/type/classes/__tests__/ObjectType.spec.ts index e73bfbb6..7a96b0c8 100644 --- a/src/type/classes/__tests__/ObjectType.spec.ts +++ b/src/type/classes/__tests__/ObjectType.spec.ts @@ -1,6 +1,51 @@ import {t} from '../..'; import type {ResolveType} from '../../../system'; +describe('.prop()', () => { + test('can add a property to an object', () => { + const obj1 = t.Object(t.prop('a', t.str)); + const obj2 = obj1.prop('b', t.num); + const val1: ResolveType = { + a: 'hello', + }; + const val2: ResolveType = { + a: 'hello', + b: 123, + }; + }); + + test('can create an object using .prop() fields', () => { + const object = t.obj.prop('a', t.str).prop('b', t.num, {title: 'B'}).prop('c', t.bool, {description: 'C'}); + expect(object.getSchema()).toMatchObject({ + kind: 'obj', + fields: [ + {kind: 'field', key: 'a', type: {kind: 'str'}}, + {kind: 'field', key: 'b', type: {kind: 'num'}, title: 'B'}, + {kind: 'field', key: 'c', type: {kind: 'bool'}, description: 'C'}, + ], + }); + }); +}); + +describe('.opt()', () => { + test('can create add optional properties', () => { + const object = t.obj + .prop('a', t.str) + .prop('b', t.num, {title: 'B'}) + .prop('c', t.bool, {description: 'C'}) + .opt('d', t.nil, {description: 'D'}); + expect(object.getSchema()).toMatchObject({ + kind: 'obj', + fields: [ + {kind: 'field', key: 'a', type: {kind: 'str'}}, + {kind: 'field', key: 'b', type: {kind: 'num'}, title: 'B'}, + {kind: 'field', key: 'c', type: {kind: 'bool'}, description: 'C'}, + {kind: 'field', key: 'd', type: {kind: 'const', value: null}, description: 'D', optional: true}, + ], + }); + }); +}); + describe('.extend()', () => { test('can extend an object', () => { const obj1 = t.Object(t.prop('a', t.str)); diff --git a/src/type/classes/__tests__/StringType.format.spec.ts b/src/type/classes/__tests__/StringType.spec.ts similarity index 79% rename from src/type/classes/__tests__/StringType.format.spec.ts rename to src/type/classes/__tests__/StringType.spec.ts index 8c544d25..121c7d5b 100644 --- a/src/type/classes/__tests__/StringType.format.spec.ts +++ b/src/type/classes/__tests__/StringType.spec.ts @@ -1,5 +1,52 @@ import {t} from '../../..'; +test('can use helper functions to define type schema fields', () => { + const string = t.String(); + expect(string.getSchema()).toEqual({kind: 'str'}); + string.title('My String'); + expect(string.getSchema()).toEqual({kind: 'str', title: 'My String'}); + string.intro('This is a string type'); + expect(string.getSchema()).toEqual({ + kind: 'str', + title: 'My String', + intro: 'This is a string type', + }); + string.description('A detailed description of the string type'); + expect(string.getSchema()).toEqual({ + kind: 'str', + title: 'My String', + intro: 'This is a string type', + description: 'A detailed description of the string type', + }); + string.min(5); + expect(string.getSchema()).toEqual({ + kind: 'str', + title: 'My String', + intro: 'This is a string type', + description: 'A detailed description of the string type', + min: 5, + }); + string.max(10); + expect(string.getSchema()).toEqual({ + kind: 'str', + title: 'My String', + intro: 'This is a string type', + description: 'A detailed description of the string type', + min: 5, + max: 10, + }); + string.format('ascii'); + expect(string.getSchema()).toEqual({ + kind: 'str', + title: 'My String', + intro: 'This is a string type', + description: 'A detailed description of the string type', + min: 5, + max: 10, + format: 'ascii', + }); +}); + describe('StringType format validation', () => { describe('ASCII format', () => { const asciiType = t.String({format: 'ascii'}); diff --git a/src/type/discriminator.ts b/src/type/discriminator.ts index 177f88a8..3177ffb7 100644 --- a/src/type/discriminator.ts +++ b/src/type/discriminator.ts @@ -1,5 +1,5 @@ -import type {Expr} from '@jsonjoy.com/json-expression'; import {BooleanType, ConstType, NumberType, type ObjectFieldType, ObjectType, StringType, TupleType} from './classes'; +import type {Expr} from '@jsonjoy.com/json-expression'; import type {Type} from './types'; export class Discriminator { diff --git a/src/type/index.ts b/src/type/index.ts index f51a0d84..475c2652 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -1,6 +1,12 @@ export * from './types'; export * from './classes'; +import type {TypeOf} from '../schema'; import {TypeBuilder} from './TypeBuilder'; +import type {SchemaOf, Type} from './types'; export const t = new TypeBuilder(); + +export namespace t { + export type infer = TypeOf>; +} diff --git a/src/value/README.md b/src/value/README.md new file mode 100644 index 00000000..3791f3ea --- /dev/null +++ b/src/value/README.md @@ -0,0 +1,7 @@ +# JSON Type Value + +A JSON Type Value is a JSON Type node and its associated runtime *value* 2-tuple. + +```ts +new Value(type, data); +``` diff --git a/src/value/index.ts b/src/value/index.ts new file mode 100644 index 00000000..a862cd28 --- /dev/null +++ b/src/value/index.ts @@ -0,0 +1,3 @@ +export {value} from './util'; +export {Value} from './Value'; +export {ObjectValue} from './ObjectValue';