From 37991436076ae9310cc8912a0fd98f3429e34028 Mon Sep 17 00:00:00 2001 From: Iwo Plaza Date: Thu, 3 Feb 2022 03:32:24 +0100 Subject: [PATCH 1/3] Added keyed types. Optional have to be explicitly "undefined". --- examples/recursiveTypes/index.ts | 91 ++++++++++-------- src/describe/index.ts | 37 +++---- src/index.ts | 2 +- src/structure/_internal.ts | 1 + src/structure/array.ts | 14 ++- src/structure/baseTypes.ts | 14 ++- src/structure/chars.ts | 4 +- src/structure/index.ts | 3 + src/structure/keyed.ts | 92 ++++++++++++++++++ src/structure/object.ts | 160 +++++++++++++++---------------- src/structure/optional.ts | 20 +++- src/structure/tuple.ts | 20 +++- src/structure/types.ts | 61 +++++++++--- src/test/_mock.test.ts | 6 +- src/test/array.test.ts | 13 +++ src/test/keyed.test.ts | 120 +++++++++++++++++++++++ src/test/object.test.ts | 106 +------------------- src/utilityTypes.ts | 16 +++- 18 files changed, 490 insertions(+), 290 deletions(-) create mode 100644 src/structure/keyed.ts create mode 100644 src/test/keyed.test.ts diff --git a/examples/recursiveTypes/index.ts b/examples/recursiveTypes/index.ts index 1646259..22f6a3d 100644 --- a/examples/recursiveTypes/index.ts +++ b/examples/recursiveTypes/index.ts @@ -2,52 +2,63 @@ // Run with `npm run example:recursiveTypes` // -import { INT, object, Parsed, ParsedConcrete, STRING, typedGeneric, typedObject } from 'typed-binary'; +import type { Parsed } from 'typed-binary'; +import { STRING, keyed, object, optional } from 'typed-binary'; -interface ExpressionBase {} +type Expression = Parsed; +const Expression = keyed('expression' as const, (Expression) => object({ + bruh: STRING, + inner: optional(Expression), +})); -interface MultiplyExpression extends ExpressionBase { - type: 'multiply'; - a: Expression; - b: Expression; -} - -interface NegateExpression extends ExpressionBase { - type: 'negate'; - inner: Expression; -} - -type IntLiteralExpression = ParsedConcrete; -const IntLiteralExpression = object({ - value: INT, +type Instruction = Parsed; +const Instruction = object({ + some: STRING, + inner: optional(Expression), }); -type Expression = MultiplyExpression|NegateExpression|IntLiteralExpression; -const Expression = typedGeneric({ - name: STRING, -}, { - 'multiply': typedObject(() => ({ - a: Expression, - b: Expression, +type Complex = Parsed; +const Complex = keyed('complex' as const, (Complex) => object({ + label: STRING, + inner: optional(Complex), + cycle: keyed('cycle' as const, (Cycle) => object({ + value: STRING, + next: optional(Cycle), })), - 'negate': typedObject(() => ({ - inner: Expression, - })), - 'int_literal': IntLiteralExpression -}); +})); -const expr: Parsed = { - type: 'multiply', - a: { - type: 'negate', - inner: { - type: 'int_literal', - value: 15, - } - }, - b: { - type: 'int_literal', - value: 2, +const expr: Expression = { + bruh: 'firstLevel', + inner: { + bruh: 'hello', + inner: undefined, }, }; +const inst: Instruction = { + some: 'firstlevel', + inner: undefined, +}; + +const complex: Complex = { + label: '1', + inner: { + label: '1->2', + inner: undefined, + cycle: { + value: '1->2: A', + next: undefined, + }, + }, + cycle: { + value: '1: B', + next: { + value: '1: B->C', + next: undefined, + }, + }, +} + +console.log(expr); +console.log(inst); +console.log(complex); \ No newline at end of file diff --git a/src/describe/index.ts b/src/describe/index.ts index ff95884..cd93a6b 100644 --- a/src/describe/index.ts +++ b/src/describe/index.ts @@ -3,53 +3,38 @@ import { ArraySchema } from '../structure/array'; import { OptionalSchema } from '../structure/optional'; import { GenericObjectSchema } from '../structure/object'; import { TupleSchema } from '../structure/tuple'; -import { ISchema, SchemaProperties } from '../structure/types'; -import { OptionalUndefined, ValueOrProvider } from '../utilityTypes'; -import { Parsed } from '..'; +import { ISchema, ISchemaWithProperties, IStableSchema, Ref, SchemaMap } from '../structure/types'; +import { KeyedSchema } from '../structure/keyed'; export const chars = (length: T) => new CharsSchema(length); -export const object =

(properties: ValueOrProvider

) => +export const object =

(properties: SchemaMap

) => new ObjectSchema(properties); -export const typedObject =

(properties: ValueOrProvider) => - new ObjectSchema>(properties); - -export const generic =

}>(properties: P, subTypeMap: ValueOrProvider) => +export const generic = }>(properties: SchemaMap

, subTypeMap: S) => new GenericObjectSchema( - SubTypeKey.STRING as any, - properties, - subTypeMap - ); - -export const typedGeneric =

(properties: ValueOrProvider, subTypeMap: any) => - new GenericObjectSchema( SubTypeKey.STRING, properties, subTypeMap ); -export const genericEnum =

}>(properties: P, subTypeMap: ValueOrProvider) => +export const genericEnum = }>(properties: SchemaMap

, subTypeMap: S) => new GenericObjectSchema( - SubTypeKey.ENUM as any, - properties, - subTypeMap - ); - -export const typedGenericEnum =

(properties: ValueOrProvider, subTypeMap: any) => - new GenericObjectSchema( SubTypeKey.ENUM, properties, subTypeMap ); -export const arrayOf = >>(elementType: T) => +export const arrayOf = >(elementType: T) => new ArraySchema(elementType); -export const tupleOf = >>(elementType: T, length: number) => +export const tupleOf = >(elementType: T, length: number) => new TupleSchema(elementType, length); -export const optional = >>(innerType: T) => +export const optional = (innerType: ISchema) => new OptionalSchema(innerType); + +export const keyed = >(key: K, inner: (ref: ISchema>) => P) => + new KeyedSchema(key, inner); diff --git a/src/index.ts b/src/index.ts index 2a6d5b4..ad92178 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export * from './structure'; export * from './describe'; export * from './io'; -export type { Parsed, ParsedConcrete } from './utilityTypes'; \ No newline at end of file +export type { Parsed } from './utilityTypes'; \ No newline at end of file diff --git a/src/structure/_internal.ts b/src/structure/_internal.ts index 6fe9ee2..a53f219 100644 --- a/src/structure/_internal.ts +++ b/src/structure/_internal.ts @@ -4,3 +4,4 @@ export * from './chars'; export * from './optional'; export * from './object'; export * from './array'; +export * from './keyed'; diff --git a/src/structure/array.ts b/src/structure/array.ts index f9ffc86..0a278db 100644 --- a/src/structure/array.ts +++ b/src/structure/array.ts @@ -1,11 +1,21 @@ import type { ISerialInput, ISerialOutput } from '../io'; import { INT } from './baseTypes'; -import { Schema } from './types'; +import { IRefResolver, ISchema, IStableSchema, Schema } from './types'; export class ArraySchema extends Schema { - constructor(public readonly elementType: Schema) { + public elementType: IStableSchema + + constructor(private readonly _unstableElementType: ISchema) { super(); + + // In case this array isn't part of a keyed chain, + // let's assume the inner type is stable. + this.elementType = _unstableElementType as IStableSchema; + } + + resolve(ctx: IRefResolver): void { + this.elementType = ctx.resolve(this._unstableElementType); } write(output: ISerialOutput, values: T[]): void { diff --git a/src/structure/baseTypes.ts b/src/structure/baseTypes.ts index ff28c48..126414a 100644 --- a/src/structure/baseTypes.ts +++ b/src/structure/baseTypes.ts @@ -6,6 +6,8 @@ import { Schema } from './types'; //// export class BoolSchema extends Schema { + resolve(): void { /* Nothing to resolve */ } + read(input: ISerialInput): boolean { return input.readBool(); } @@ -26,15 +28,17 @@ export const BOOL = new BoolSchema(); //// export class StringSchema extends Schema { + resolve(): void { /* Nothing to resolve */ } + read(input: ISerialInput): string { return input.readString(); } - write(output: ISerialOutput, value: string): void { + write(output: ISerialOutput, value: T): void { output.writeString(value); } - sizeOf(value: string): number { + sizeOf(value: T): number { return value.length + 1; } } @@ -46,6 +50,8 @@ export const STRING = new StringSchema(); //// export class ByteSchema extends Schema { + resolve(): void { /* Nothing to resolve */ } + read(input: ISerialInput): number { return input.readByte(); } @@ -66,6 +72,8 @@ export const BYTE = new ByteSchema(); //// export class IntSchema extends Schema { + resolve(): void { /* Nothing to resolve */ } + read(input: ISerialInput): number { return input.readInt(); } @@ -86,6 +94,8 @@ export const INT = new IntSchema(); //// export class FloatSchema extends Schema { + resolve(): void { /* Nothing to resolve */ } + read(input: ISerialInput): number { return input.readFloat(); } diff --git a/src/structure/chars.ts b/src/structure/chars.ts index 4c8626c..50d8b91 100644 --- a/src/structure/chars.ts +++ b/src/structure/chars.ts @@ -1,5 +1,5 @@ -import { TypedBinaryError } from '../error'; import type { ISerialInput, ISerialOutput } from '../io'; +import { TypedBinaryError } from '../error'; import { Schema } from './types'; export class CharsSchema extends Schema { @@ -7,6 +7,8 @@ export class CharsSchema extends Schema { super(); } + resolve(): void { /* Nothing to resolve */ } + write(output: ISerialOutput, value: string): void { if (value.length !== this.length) { throw new TypedBinaryError(`Expected char-string of length ${this.length}, got ${value.length}`); diff --git a/src/structure/index.ts b/src/structure/index.ts index 03b0343..37bc40c 100644 --- a/src/structure/index.ts +++ b/src/structure/index.ts @@ -5,6 +5,9 @@ export { FLOAT, STRING, + Ref, + Keyed, + KeyedSchema, ObjectSchema, CharsSchema, ArraySchema, diff --git a/src/structure/keyed.ts b/src/structure/keyed.ts new file mode 100644 index 0000000..40b77df --- /dev/null +++ b/src/structure/keyed.ts @@ -0,0 +1,92 @@ +import { Parsed } from '..'; +import { TypedBinaryError } from '../error'; +import { ISerialInput, ISerialOutput } from '../io'; +import { IRefResolver, ISchema, IStableSchema, Keyed, Ref } from './types'; + +class RefSchema implements IStableSchema> { + public readonly _infered!: Ref; + public readonly ref: Ref; + + constructor(key: K) { + this.ref = new Ref(key); + } + + resolve(): void { + throw new TypedBinaryError(`Tried to resolve a reference directly. Do it through a RefResolver instead.`); + } + + read(): Ref { + throw new TypedBinaryError(`Tried to read a reference directly. Resolve it instead.`); + } + + write(): void { + throw new TypedBinaryError(`Tried to write a reference directly. Resolve it instead.`); + } + + sizeOf(): number { + throw new TypedBinaryError(`Tried to estimate size of a reference directly. Resolve it instead.`); + } +} + +class RefResolve implements IRefResolver { + private registry: {[key: string]: IStableSchema} = {}; + + hasKey(key: string): boolean { + return this.registry[key] !== undefined; + } + + register(key: K, schema: IStableSchema): void { + this.registry[key] = schema; + } + + resolve(unstableSchema: ISchema): IStableSchema { + if (unstableSchema instanceof RefSchema) { + const ref = unstableSchema.ref; + const key = ref.key as string; + if (this.registry[key] !== undefined) { + return this.registry[key] as IStableSchema; + } + + throw new TypedBinaryError(`Couldn't resolve reference to ${key}. Unknown key.`); + } + + // Since it's not a RefSchema, we assume it can be resolved. + (unstableSchema as IStableSchema).resolve(this); + + return unstableSchema as IStableSchema; + } +} + +export class KeyedSchema> implements ISchema> { + public readonly _infered!: Keyed; + public innerType: S; + + constructor(public readonly key: K, innerResolver: (ref: ISchema>) => S) { + this.innerType = innerResolver(new RefSchema(key)); + this._infered = new Keyed(key, this.innerType); + + // Automatically resolving after keyed creation. + this.resolve(new RefResolve()); + } + + resolve(ctx: IRefResolver): void { + if (!ctx.hasKey(this.key)) { + ctx.register(this.key, this.innerType); + + this.innerType.resolve(ctx); + } + } + + read(input: ISerialInput): Parsed { + return this.innerType.read(input) as Parsed; + } + + write(output: ISerialOutput, value: Parsed): void { + this.innerType.write(output, value); + } + + sizeOf(value: Parsed): number { + return this.innerType.sizeOf(value); + } +} + diff --git a/src/structure/object.ts b/src/structure/object.ts index f3fae3c..2e86a64 100644 --- a/src/structure/object.ts +++ b/src/structure/object.ts @@ -1,93 +1,98 @@ -import { Parsed } from '..'; import type { ISerialInput, ISerialOutput } from '../io'; -import type { OptionalUndefined, ValueOrProvider } from '../utilityTypes'; import { STRING } from './baseTypes'; import { - Schema, InferedProperties, SchemaProperties + Schema, IRefResolver, ISchemaWithProperties, SchemaMap, StableSchemaMap, SchemaWithPropertiesMap } from './types'; import { SubTypeKey } from './types'; +export function exactEntries>(record: T): [keyof T, T[keyof T]][] { + return Object.entries(record) as [keyof T, T[keyof T]][]; +} -export class ObjectSchema< - T extends SchemaProperties, - O extends OptionalUndefined> = OptionalUndefined> -> extends Schema { - private cachedProperties?: T; +export function resolveMap>>(ctx: IRefResolver, refs: SchemaWithPropertiesMap): StableObjectSchemaMap; +export function resolveMap(ctx: IRefResolver, refs: SchemaMap): StableSchemaMap; +export function resolveMap(ctx: IRefResolver, refs: SchemaMap): StableSchemaMap { + const props = {} as StableSchemaMap; - constructor(private readonly _properties: ValueOrProvider) { - super(); + for (const [key, ref] of exactEntries(refs)) { + props[key] = ctx.resolve(ref); } - public get properties() { - return this.cachedProperties || ( - this.cachedProperties = (typeof this._properties === 'function' ? this._properties() : this._properties) - ); + return props; +} + +export type StableObjectSchemaMap>> = {[key in keyof T]: ObjectSchema}; + +export class ObjectSchema extends Schema implements ISchemaWithProperties { + public properties: StableSchemaMap; + + constructor(private readonly _properties: SchemaMap) { + super(); + + // In case this object isn't part of a keyed chain, + // let's assume properties are stable. + this.properties = _properties as StableSchemaMap; } - write(output: ISerialOutput, value: O): void { - const keys: string[] = Object.keys(this.properties); + resolve(ctx: IRefResolver): void { + this.properties = resolveMap(ctx, this._properties); + } - for (const key of keys) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this.properties[key].write(output, (value as any)[key]); + write(output: ISerialOutput, value: I): void { + for (const [key, property] of exactEntries(this.properties)) { + property.write(output, value[key]); } } - read(input: ISerialInput): O { - const keys: (keyof T)[] = Object.keys(this.properties); - const result = {} as O; + read(input: ISerialInput): T { + const result = {} as T; - for (const key of keys) { - const value = this.properties[key].read(input); - if (value !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (result as any)[key] = value; - } + for (const [key, property] of exactEntries(this.properties)) { + result[key] = property.read(input); } return result; } - sizeOf(value: O): number { - let size = 0; - - // Going through the base properties - size += Object.keys(this.properties) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - .map(key => this.properties[key].sizeOf((value as any)[key])) // Mapping properties into their sizes. + sizeOf(value: I): number { + return exactEntries(this.properties) + .map(([key, property]) => property.sizeOf(value[key])) // Mapping properties into their sizes. .reduce((a, b) => a + b); // Summing them up - - return size; } } -export type InferedSubTypes}> = { - [Key in keyof T]: Parsed & { type: Key } -}; - -export type ObjectSchemaMap = {[key in keyof S]: ObjectSchema}; +export type AsSubTypes = ({[K in keyof T]: T[K] & { type: K }})[keyof T]; export class GenericObjectSchema< - T extends SchemaProperties, // Base properties - S extends {[Key in keyof S]: ObjectSchema}, // Sub type map - K extends string|number, - I extends OptionalUndefined> & InferedSubTypes[keyof S] = OptionalUndefined> & InferedSubTypes[keyof S] -> extends ObjectSchema { + T extends Record, // Base properties + E extends {[Key in keyof E]: Record}, // Sub type map +> extends Schema> { + private _baseObject: ObjectSchema; + public subTypeMap: {[key in keyof E]: ObjectSchema}; + constructor( - public readonly keyedBy: K, - properties: ValueOrProvider, - private readonly subTypeMap: ValueOrProvider + public readonly keyedBy: SubTypeKey, + properties: SchemaMap, + private readonly _subTypeMap: {[key in keyof E]: ISchemaWithProperties} ) { - super(properties); + super(); + + this._baseObject = new ObjectSchema(properties); + + // In case this object isn't part of a keyed chain, + // let's assume sub types are stable. + this.subTypeMap = _subTypeMap as typeof this.subTypeMap; } - private getSubTypeMap(): S { - return typeof this.subTypeMap === 'function' ? this.subTypeMap() : this.subTypeMap; + resolve(ctx: IRefResolver): void { + this._baseObject.resolve(ctx); + this.subTypeMap = resolveMap(ctx, this._subTypeMap); } - write(output: ISerialOutput, value: I): void { + write(output: ISerialOutput, value: T & AsSubTypes): void { // Figuring out sub-types - const subTypeDescription = this.getSubTypeMap()[value.type] || null; + + const subTypeDescription = this.subTypeMap[value.type] || null; if (subTypeDescription === null) { throw new Error(`Unknown sub-type '${value.type.toString()}' in among '${JSON.stringify(Object.keys(this.subTypeMap))}'`); } @@ -101,64 +106,51 @@ export class GenericObjectSchema< } // Writing the base properties - super.write(output, value); + this._baseObject.write(output, value); // Extra sub-type fields - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const extraKeys: string[] = Object.keys(subTypeDescription.properties); - - for (const key of extraKeys) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const prop: Schema = subTypeDescription.properties[key]; - - prop.write(output, value[key]); + for (const [key, extraProp] of exactEntries(subTypeDescription.properties)) { + extraProp.write(output, value[key]); } } - read(input: ISerialInput): I { - const subTypeMap = this.getSubTypeMap(); + read(input: ISerialInput): T & AsSubTypes { const subTypeKey = this.keyedBy === SubTypeKey.ENUM ? input.readByte() : input.readString(); - const subTypeDescription = subTypeMap[subTypeKey as keyof S] || null; + const subTypeDescription = this.subTypeMap[subTypeKey as keyof E] || null; if (subTypeDescription === null) { - throw new Error(`Unknown sub-type '${subTypeKey}' in among '${JSON.stringify(Object.keys(subTypeMap))}'`); + throw new Error(`Unknown sub-type '${subTypeKey}' in among '${JSON.stringify(Object.keys(this.subTypeMap))}'`); } - const result = super.read(input); + const result = this._baseObject.read(input) as T & AsSubTypes; // Making the sub type key available to the result object. - result.type = subTypeKey as keyof S; + result.type = subTypeKey as keyof E; if (subTypeDescription !== null) { - const extraKeys = Object.keys(subTypeDescription.properties); - - for (const key of extraKeys) { - const prop = (subTypeDescription.properties)[key]; - const value = prop.read(input); - if (value !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (result as any)[key] = value; - } + for (const [key, extraProp] of exactEntries(subTypeDescription.properties)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (result as any)[key] = extraProp.read(input); } } return result; } - sizeOf(value: I): number { - let size = super.sizeOf(value); + sizeOf(value: T & AsSubTypes): number { + let size = this._baseObject.sizeOf(value); // We're a generic object trying to encode a concrete value. size += this.keyedBy === SubTypeKey.ENUM ? 1 : STRING.sizeOf(value.type as string); // Extra sub-type fields - const subTypeDescription = this.getSubTypeMap()[value.type] || null; + const subTypeDescription = this.subTypeMap[value.type] || null; if (subTypeDescription === null) { throw new Error(`Unknown sub-type '${value.type.toString()}' in among '${JSON.stringify(Object.keys(this.subTypeMap))}'`); } - size += Object.keys(subTypeDescription.properties) // Going through extra property keys - .map(key => subTypeDescription.properties[key].sizeOf(value[key])) // Mapping extra properties into their sizes + size += exactEntries(subTypeDescription.properties) // Going through extra property keys + .map(([key, prop]) => prop.sizeOf(value[key])) // Mapping extra properties into their sizes .reduce((a, b) => a + b, 0); // Summing them up return size; diff --git a/src/structure/optional.ts b/src/structure/optional.ts index fb8b632..5895c2c 100644 --- a/src/structure/optional.ts +++ b/src/structure/optional.ts @@ -1,15 +1,25 @@ import type { ISerialInput, ISerialOutput } from '../io'; -import { Schema } from './types'; +import { IRefResolver, ISchema, IStableSchema, Schema } from './types'; export class OptionalSchema extends Schema { - constructor(public readonly innerType: Schema) { + private innerSchema: IStableSchema; + + constructor(private readonly _innerUnstableSchema: ISchema) { super(); + + // In case this optional isn't part of a keyed chain, + // let's assume the inner type is stable. + this.innerSchema = _innerUnstableSchema as IStableSchema; + } + + resolve(ctx: IRefResolver): void { + this.innerSchema = ctx.resolve(this._innerUnstableSchema); } write(output: ISerialOutput, value: T|undefined): void { if (value !== undefined && value !== null) { output.writeBool(true); - this.innerType.write(output, value); + this.innerSchema.write(output, value); } else { output.writeBool(false); @@ -20,7 +30,7 @@ export class OptionalSchema extends Schema { const valueExists = input.readBool(); if (valueExists) { - return this.innerType.read(input); + return this.innerSchema.read(input); } return undefined; @@ -30,6 +40,6 @@ export class OptionalSchema extends Schema { if (value === undefined) return 1; - return 1 + this.innerType.sizeOf(value); + return 1 + this.innerSchema.sizeOf(value); } } diff --git a/src/structure/tuple.ts b/src/structure/tuple.ts index 335a404..c1049e1 100644 --- a/src/structure/tuple.ts +++ b/src/structure/tuple.ts @@ -1,10 +1,20 @@ import { TypedBinaryError } from '../error'; -import { Schema } from './types'; +import { IRefResolver, ISchema, IStableSchema, Schema } from './types'; import type { ISerialInput, ISerialOutput } from '../io'; export class TupleSchema extends Schema { - constructor(public readonly elementType: Schema, public readonly length: number) { + private elementSchema: IStableSchema; + + constructor(private readonly _unstableElementSchema: ISchema, public readonly length: number) { super(); + + // In case this array isn't part of a keyed chain, + // let's assume the inner type is stable. + this.elementSchema = _unstableElementSchema as IStableSchema; + } + + resolve(ctx: IRefResolver): void { + this.elementSchema = ctx.resolve(this._unstableElementSchema); } write(output: ISerialOutput, values: T[]): void { @@ -13,7 +23,7 @@ export class TupleSchema extends Schema { } for (const value of values) { - this.elementType.write(output, value); + this.elementSchema.write(output, value); } } @@ -21,13 +31,13 @@ export class TupleSchema extends Schema { const array = []; for (let i = 0; i < this.length; ++i) { - array.push(this.elementType.read(input)); + array.push(this.elementSchema.read(input)); } return array; } sizeOf(values: T[]): number { - return values.map(v => this.elementType.sizeOf(v)).reduce((a, b) => a + b); + return values.map(v => this.elementSchema.sizeOf(v)).reduce((a, b) => a + b); } } diff --git a/src/structure/types.ts b/src/structure/types.ts index d92b79b..fa0d3d3 100644 --- a/src/structure/types.ts +++ b/src/structure/types.ts @@ -1,20 +1,44 @@ -import { Parsed } from '..'; import { ISerialInput, ISerialOutput } from '../io'; -export interface ISchema

{ - write(output: ISerialOutput, value: P): void; - read(input: ISerialInput): P; - sizeOf(value: P): number; +/** + * A schema that hasn't been resolved yet (a reference). + * Distinguishing between a "stable" schema, and an "unstable" schema + * helps to avoid errors in usage of unresolved schemas (the lack of utility functions). + */ +export interface ISchema { + readonly _infered: I; } -export abstract class Schema

implements ISchema

{ - abstract write(output: ISerialOutput, value: P): void; - abstract read(input: ISerialInput): P; - abstract sizeOf(value: P): number; +export interface ISchemaWithProperties extends ISchema { + readonly properties: StableSchemaMap; +} + +export interface IStableSchema extends ISchema { + resolve(ctx: IRefResolver): void; + write(output: ISerialOutput, value: I): void; + read(input: ISerialInput): I; + sizeOf(value: I): number; +} + +export abstract class Schema implements IStableSchema { + readonly _infered!: I; + + abstract resolve(ctx: IRefResolver): void; + abstract write(output: ISerialOutput, value: I): void; + abstract read(input: ISerialInput): I; + abstract sizeOf(value: I): number; +} + +export class Ref { + constructor(public readonly key: K) {} +} + +export class Keyed> { + constructor(public readonly key: K, public readonly innerType: S) {} } //// -// CONTEXT +// Generic types //// export enum SubTypeKey { @@ -22,10 +46,17 @@ export enum SubTypeKey { ENUM = 'ENUM', } -// export type SchemaProperties = T extends {[key in keyof T]: Schema} ? {[key in keyof T]: Schema>} : never; -export type SchemaProperties = {[key: string]: Schema}; -export type InferedProperties}> = {[key in keyof T]: Parsed}; +export interface IRefResolver { + hasKey(key: string): boolean; -export interface IConcreteObjectSchema extends ISchema> { - readonly properties: T; + resolve(schemaOrRef: ISchema): IStableSchema; + register(key: K, schema: IStableSchema): void; } + +//// +// Alias types +//// + +export type SchemaMap = {[key in keyof T]: ISchema}; +export type StableSchemaMap = {[key in keyof T]: IStableSchema}; +export type SchemaWithPropertiesMap>> = {[key in keyof T]: ISchemaWithProperties}; \ No newline at end of file diff --git a/src/test/_mock.test.ts b/src/test/_mock.test.ts index 20d6d71..5c82afd 100644 --- a/src/test/_mock.test.ts +++ b/src/test/_mock.test.ts @@ -1,5 +1,5 @@ import { BufferReader, BufferWriter } from '../io'; -import { ISchema } from '../structure/types'; +import { IStableSchema } from '../structure/types'; import { Parsed } from '../utilityTypes'; export function makeIO(bufferSize: number) { @@ -10,10 +10,10 @@ export function makeIO(bufferSize: number) { }; } -export function encodeAndDecode>>(schema: T, value: Parsed): Parsed { +export function encodeAndDecode>(schema: T, value: Parsed): Parsed { const buffer = Buffer.alloc(schema.sizeOf(value)); schema.write(new BufferWriter(buffer), value); - return schema.read(new BufferReader(buffer)); + return schema.read(new BufferReader(buffer)) as Parsed; } diff --git a/src/test/array.test.ts b/src/test/array.test.ts index 0daa82b..a2d7594 100644 --- a/src/test/array.test.ts +++ b/src/test/array.test.ts @@ -2,10 +2,23 @@ import * as chai from 'chai'; import { randIntBetween } from './random'; import { makeIO } from './_mock.test'; import { ArraySchema, INT } from '../structure'; +import { arrayOf } from '..'; const expect = chai.expect; describe('ArrayScheme', () => { + it('should estimate an int-array encoding size', () => { + const IntArray = arrayOf(INT); + + const length = randIntBetween(0, 200); + const values = []; + for (let i = 0; i < length; ++i) { + values.push(randIntBetween(-10000, 10000)); + } + + expect(IntArray.sizeOf(values)).to.equal(INT.sizeOf() + length * INT.sizeOf()); + }); + it('should encode and decode a simple int array', () => { const length = randIntBetween(0, 5); const value = []; diff --git a/src/test/keyed.test.ts b/src/test/keyed.test.ts new file mode 100644 index 0000000..1787886 --- /dev/null +++ b/src/test/keyed.test.ts @@ -0,0 +1,120 @@ +import * as chai from 'chai'; +import { encodeAndDecode } from './_mock.test'; +import { INT, STRING } from '../structure/baseTypes'; +import { keyed, object, optional } from '../describe'; +import { Parsed } from '..'; + +const expect = chai.expect; + +describe('KeyedSchema', () => { + it('should encode and decode a keyed object, no references', () => { + const Example = keyed('example-key' as const, () => object({ + value: INT, + label: STRING, + })); + + const value = { + value: 70, + label: 'Banana', + }; + + const decoded = encodeAndDecode(Example, value); + expect(decoded).to.deep.equal(value); + }); + + it('should encode and decode a keyed object, with 0-level-deep references', () => { + const Example = keyed('example-key' as const, (Example) => object({ + value: INT, + label: STRING, + next: optional(Example), + })); + + const value = { + value: 70, + label: 'Banana', + next: undefined, + }; + + const decoded = encodeAndDecode(Example, value); + expect(decoded).to.deep.equal(value); + }); + + it('should encode and decode a keyed object, with 1-level-deep references', () => { + const Example = keyed('example-key' as const, (Example) => object({ + value: INT, + label: STRING, + next: optional(Example), + })); + + const value: Parsed = { + value: 70, + label: 'Banana', + next: { + value: 20, + label: 'Inner Banana', + next: undefined, + }, + }; + + const decoded = encodeAndDecode(Example, value); + expect(decoded).to.deep.equal(value); + }); + + it('should encode and decode a keyed object, with 2-level-deep references', () => { + const Example = keyed('example-key' as const, (Example) => object({ + value: INT, + label: STRING, + next: optional(Example), + })); + + const value: Parsed = { + value: 70, + label: 'Banana', + next: { + value: 20, + label: 'Inner Banana', + next: { + value: 30, + label: 'Level-2 Banana', + next: undefined, + }, + }, + }; + + const decoded = encodeAndDecode(Example, value); + expect(decoded).to.deep.equal(value); + }); + + it('should encode and decode a keyed object, with an inner keyed-object', () => { + const Example = keyed('example-key' as const, (Example) => object({ + label: STRING, + next: optional(Example), + tree: keyed('tree' as const, (Tree) => object({ + value: INT, + child: optional(Tree), + })), + })); + + const value: Parsed = { + label: 'Banana', + next: { + label: 'Inner Banana', + next: undefined, + tree: { + value: 15, + child: undefined, + }, + }, + tree: { + value: 21, + child: { + value: 23, + child: undefined, + }, + }, + }; + + const decoded = encodeAndDecode(Example, value); + expect(decoded).to.deep.equal(value); + }); +}); diff --git a/src/test/object.test.ts b/src/test/object.test.ts index d4a7ef3..6f91a94 100644 --- a/src/test/object.test.ts +++ b/src/test/object.test.ts @@ -1,7 +1,7 @@ import * as chai from 'chai'; import { encodeAndDecode, makeIO } from './_mock.test'; import { INT, STRING } from '../structure/baseTypes'; -import { generic, genericEnum, object, optional, typedObject, typedGeneric, typedGenericEnum } from '../describe'; +import { generic, genericEnum, object, optional } from '../describe'; import { Parsed } from '../utilityTypes'; const expect = chai.expect; @@ -23,24 +23,19 @@ describe('ObjectSchema', () => { expect(description.read(input)).to.deep.equal(value); }); - it('should treat undefined properties as optional', () => { + it('should treat optional properties as undefined', () => { const OptionalString = optional(STRING); const schema = object({ required: STRING, optional: OptionalString, }); - const valueWithMissing = { - required: 'Required', - }; - const valueWithUndefined = { required: 'Required', optional: undefined, }; - expect(encodeAndDecode(schema, valueWithUndefined)).to.deep.equal(valueWithMissing); - expect(encodeAndDecode(schema, valueWithMissing)).to.deep.equal(valueWithMissing); + expect(encodeAndDecode(schema, valueWithUndefined)).to.deep.equal(valueWithUndefined); }); it('should encode and decode a generic object', () => { @@ -110,99 +105,4 @@ describe('ObjectSchema', () => { expect(input.readInt()).to.equal(3); // c expect(input.readInt()).to.equal(2); // b }); - - it ('allows for type-hints', () => { - interface Explicit { - value: number; - next?: Explicit; - } - - const schema = typedObject(() => ({ - value: INT, - next: optional(schema), - })); - - const value: Explicit = { - value: 5, - }; - - const decoded = encodeAndDecode(schema, value); - expect(decoded).to.deep.equal(value); - }); - - it ('allows for generic type-hints', () => { - interface ExplicitBase { - base: number; - } - - interface ExplicitA extends ExplicitBase { - type: 'a'; - a: string; - } - - interface ExplicitB extends ExplicitBase { - type: 'b'; - b: string; - } - - type Explicit = ExplicitA|ExplicitB; - - const schema = typedGeneric({ - base: INT, - }, { - ['a' as const]: object({ - a: STRING, - }), - ['b' as const]: object({ - b: STRING, - }), - }); - - const value = { - type: 'a' as const, - base: 15, - a: 'some', - }; - - const decoded = encodeAndDecode(schema, value); - expect(decoded).to.deep.equal(value); - }); - - it ('allows for generic enum type-hints', () => { - interface ExplicitBase { - base: number; - } - - interface ExplicitA extends ExplicitBase { - type: 0; - a: string; - } - - interface ExplicitB extends ExplicitBase { - type: 1; - b: string; - } - - type Explicit = ExplicitA|ExplicitB; - - const schema = typedGenericEnum({ - base: INT, - }, { - 0: object({ - a: STRING, - }), - 1: object({ - b: STRING, - }), - }); - - const value = { - type: 0 as const, - base: 15, - a: 'some', - }; - - const decoded = encodeAndDecode(schema, value); - expect(decoded).to.deep.equal(value); - }); }); diff --git a/src/utilityTypes.ts b/src/utilityTypes.ts index ef99ec0..57ca82d 100644 --- a/src/utilityTypes.ts +++ b/src/utilityTypes.ts @@ -1,7 +1,17 @@ -import { ISchema } from './structure/types'; +import { ISchema, Keyed, Ref } from './structure/types'; + +export type Parsed> = + T extends Keyed ? + Parsed : + T extends Ref ? + (K extends keyof M ? Parsed : never) : + T extends ISchema ? + Parsed : + T extends Record ? + {[K in keyof T]: Parsed} : + T; + -export type Parsed = T extends ISchema ? I : never; -export type ParsedConcrete = B & Parsed & { type: ConcreteType }; export type ValueOrProvider = T | (() => T); type UndefinedKeys = { From 24ecb80cb171202ba7fd5dd454e07d7913d5b953 Mon Sep 17 00:00:00 2001 From: Iwo Plaza Date: Sun, 6 Feb 2022 00:25:06 +0100 Subject: [PATCH 2/3] Generic enum subtypes are typed correctly. (+more) - Generic enum schemas now have proper numbered indices, instead of string ones. - Added more unit-tests for keyed objects. --- src/describe/index.ts | 8 ++--- src/structure/object.ts | 15 ++++---- src/structure/types.ts | 3 +- src/test/keyed.test.ts | 77 ++++++++++++++++++++++++++++++++++++----- src/test/object.test.ts | 25 ++++++++----- 5 files changed, 99 insertions(+), 29 deletions(-) diff --git a/src/describe/index.ts b/src/describe/index.ts index cd93a6b..7a85f9c 100644 --- a/src/describe/index.ts +++ b/src/describe/index.ts @@ -13,15 +13,15 @@ export const chars = (length: T) => export const object =

(properties: SchemaMap

) => new ObjectSchema(properties); -export const generic = }>(properties: SchemaMap

, subTypeMap: S) => - new GenericObjectSchema( +export const generic =

, S extends {[Key in keyof S]: ISchemaWithProperties>}>(properties: SchemaMap

, subTypeMap: S) => + new GenericObjectSchema( SubTypeKey.STRING, properties, subTypeMap ); -export const genericEnum = }>(properties: SchemaMap

, subTypeMap: S) => - new GenericObjectSchema( +export const genericEnum =

, S extends {[Key in keyof S]: ISchemaWithProperties>}>(properties: SchemaMap

, subTypeMap: S) => + new GenericObjectSchema( SubTypeKey.ENUM, properties, subTypeMap diff --git a/src/structure/object.ts b/src/structure/object.ts index 2e86a64..011b8a4 100644 --- a/src/structure/object.ts +++ b/src/structure/object.ts @@ -1,7 +1,7 @@ import type { ISerialInput, ISerialOutput } from '../io'; import { STRING } from './baseTypes'; import { - Schema, IRefResolver, ISchemaWithProperties, SchemaMap, StableSchemaMap, SchemaWithPropertiesMap + Schema, IRefResolver, ISchemaWithProperties, SchemaMap, StableSchemaMap } from './types'; import { SubTypeKey } from './types'; @@ -9,7 +9,7 @@ export function exactEntries>(record: T): return Object.entries(record) as [keyof T, T[keyof T]][]; } -export function resolveMap>>(ctx: IRefResolver, refs: SchemaWithPropertiesMap): StableObjectSchemaMap; +export function resolveMap>}>(ctx: IRefResolver, refs: T): StabilizedMap; export function resolveMap(ctx: IRefResolver, refs: SchemaMap): StableSchemaMap; export function resolveMap(ctx: IRefResolver, refs: SchemaMap): StableSchemaMap { const props = {} as StableSchemaMap; @@ -61,19 +61,20 @@ export class ObjectSchema extends Schema } } -export type AsSubTypes = ({[K in keyof T]: T[K] & { type: K }})[keyof T]; +export type AsSubTypes = ({[K in keyof T]: T[K] extends ISchemaWithProperties ? P & { type: K } : never})[keyof T]; +export type StabilizedMap = ({[K in keyof T]: T[K] extends ISchemaWithProperties ? ObjectSchema

: never}); export class GenericObjectSchema< T extends Record, // Base properties - E extends {[Key in keyof E]: Record}, // Sub type map + E extends {[key in keyof E]: ISchemaWithProperties>}, // Sub type map > extends Schema> { private _baseObject: ObjectSchema; - public subTypeMap: {[key in keyof E]: ObjectSchema}; + public subTypeMap: StabilizedMap; constructor( public readonly keyedBy: SubTypeKey, properties: SchemaMap, - private readonly _subTypeMap: {[key in keyof E]: ISchemaWithProperties} + private readonly _subTypeMap: E ) { super(); @@ -81,7 +82,7 @@ export class GenericObjectSchema< // In case this object isn't part of a keyed chain, // let's assume sub types are stable. - this.subTypeMap = _subTypeMap as typeof this.subTypeMap; + this.subTypeMap = _subTypeMap as unknown as typeof this.subTypeMap; } resolve(ctx: IRefResolver): void { diff --git a/src/structure/types.ts b/src/structure/types.ts index fa0d3d3..9ffdc86 100644 --- a/src/structure/types.ts +++ b/src/structure/types.ts @@ -58,5 +58,4 @@ export interface IRefResolver { //// export type SchemaMap = {[key in keyof T]: ISchema}; -export type StableSchemaMap = {[key in keyof T]: IStableSchema}; -export type SchemaWithPropertiesMap>> = {[key in keyof T]: ISchemaWithProperties}; \ No newline at end of file +export type StableSchemaMap = {[key in keyof T]: IStableSchema}; \ No newline at end of file diff --git a/src/test/keyed.test.ts b/src/test/keyed.test.ts index 1787886..1251c15 100644 --- a/src/test/keyed.test.ts +++ b/src/test/keyed.test.ts @@ -1,14 +1,14 @@ import * as chai from 'chai'; import { encodeAndDecode } from './_mock.test'; import { INT, STRING } from '../structure/baseTypes'; -import { keyed, object, optional } from '../describe'; +import { keyed, object, generic, optional } from '../describe'; import { Parsed } from '..'; const expect = chai.expect; describe('KeyedSchema', () => { it('should encode and decode a keyed object, no references', () => { - const Example = keyed('example-key' as const, () => object({ + const Example = keyed('example', () => object({ value: INT, label: STRING, })); @@ -23,7 +23,7 @@ describe('KeyedSchema', () => { }); it('should encode and decode a keyed object, with 0-level-deep references', () => { - const Example = keyed('example-key' as const, (Example) => object({ + const Example = keyed('example', (Example) => object({ value: INT, label: STRING, next: optional(Example), @@ -40,7 +40,7 @@ describe('KeyedSchema', () => { }); it('should encode and decode a keyed object, with 1-level-deep references', () => { - const Example = keyed('example-key' as const, (Example) => object({ + const Example = keyed('example', (Example) => object({ value: INT, label: STRING, next: optional(Example), @@ -61,7 +61,7 @@ describe('KeyedSchema', () => { }); it('should encode and decode a keyed object, with 2-level-deep references', () => { - const Example = keyed('example-key' as const, (Example) => object({ + const Example = keyed('example', (Example) => object({ value: INT, label: STRING, next: optional(Example), @@ -86,16 +86,17 @@ describe('KeyedSchema', () => { }); it('should encode and decode a keyed object, with an inner keyed-object', () => { - const Example = keyed('example-key' as const, (Example) => object({ + type Example = Parsed; + const Example = keyed('example', (Example) => object({ label: STRING, next: optional(Example), - tree: keyed('tree' as const, (Tree) => object({ + tree: keyed('tree', (Tree) => object({ value: INT, child: optional(Tree), })), })); - const value: Parsed = { + const value: Example = { label: 'Banana', next: { label: 'Inner Banana', @@ -117,4 +118,64 @@ describe('KeyedSchema', () => { const decoded = encodeAndDecode(Example, value); expect(decoded).to.deep.equal(value); }); + + it('should encode and decode a keyed generic object, no references', () => { + type Example = Parsed; + const Example = keyed('example', () => generic({ + label: STRING, + }, { + primary: object({ + primaryExtra: INT, + }), + secondary: object({ + secondaryExtra: INT, + }) + })); + + const value: Example = { + label: 'Example Label', + type: 'primary', + primaryExtra: 15, + }; + + const decoded = encodeAndDecode(Example, value); + expect(decoded).to.deep.equal(value); + }); + + it('should encode and decode a keyed generic object, with references', () => { + type Example = Parsed; + const Example = keyed('example', (Example) => generic({ + label: STRING, + }, { + continuous: object({ + next: optional(Example), + }), + fork: object({ + left: optional(Example), + right: optional(Example), + }) + })); + + const value: Example = { + label: 'Root', + type: 'continuous', + next: { + label: 'Level 1', + type: 'fork', + left: { + label: 'Level 2-A', + type: 'continuous', + next: undefined, + }, + right: { + label: 'Level 2-B', + type: 'continuous', + next: undefined, + }, + }, + }; + + const decoded = encodeAndDecode(Example, value); + expect(decoded).to.deep.equal(value); + }); }); diff --git a/src/test/object.test.ts b/src/test/object.test.ts index 6f91a94..78188a4 100644 --- a/src/test/object.test.ts +++ b/src/test/object.test.ts @@ -6,6 +6,7 @@ import { Parsed } from '../utilityTypes'; const expect = chai.expect; + describe('ObjectSchema', () => { it('should encode and decode a simple object', () => { const description = object({ @@ -39,16 +40,20 @@ describe('ObjectSchema', () => { }); it('should encode and decode a generic object', () => { - const genericDescription = + type GenericType = Parsed; + const GenericType = generic({ sharedValue: INT, }, { 'concrete': object({ extraValue: INT, }), + 'other': object({ + notImportant: INT, + }), }); - const value = { + const value: GenericType = { type: 'concrete' as const, sharedValue: 100, extraValue: 10, @@ -56,22 +61,26 @@ describe('ObjectSchema', () => { const { output, input } = makeIO(64); // Writing with the generic description. - genericDescription.write(output, value); + GenericType.write(output, value); // Reading with the generic description. - expect(genericDescription.read(input)).to.deep.equal(value); + expect(GenericType.read(input)).to.deep.equal(value); }); it('should encode and decode an enum generic object', () => { - const genericDescription = + type GenericType = Parsed; + const GenericType = genericEnum({ sharedValue: INT, }, { 0: object({ extraValue: INT, }), + 1: object({ + notImportant: INT, + }), }); - const value = { + const value: GenericType = { type: 0 as const, sharedValue: 100, extraValue: 10, @@ -79,9 +88,9 @@ describe('ObjectSchema', () => { const { output, input } = makeIO(64); // Writing with the generic description. - genericDescription.write(output, value); + GenericType.write(output, value); // Reading with the generic description. - expect(genericDescription.read(input)).to.deep.equal(value); + expect(GenericType.read(input)).to.deep.equal(value); }); it('preserves insertion-order of properties', () => { From 204354025743e8954545c5a9adb24e32fa39e3a0 Mon Sep 17 00:00:00 2001 From: Iwo Plaza Date: Sun, 8 May 2022 23:29:02 +0200 Subject: [PATCH 3/3] Types are now properly infered for recursive keyed types. (+more) - Documented the usage of keyed types. - Added more example code. --- README.md | 127 ++++++++++++++++------ examples/__util/index.ts | 10 ++ examples/customSchema/index.ts | 24 ++++ examples/customSchema/radians.ts | 39 +++++++ examples/genericEnumTypes/index.ts | 61 +++++++++++ examples/genericTypes/index.ts | 58 ++++++++++ examples/package.json | 8 +- examples/recursiveTypes/index.ts | 49 ++++++--- examples/stateMachine/connection.ts | 24 ++++ examples/stateMachine/graph.ts | 9 ++ examples/stateMachine/index.ts | 34 ++++++ examples/stateMachine/node.ts | 12 ++ examples/stateMachine/triggerCondition.ts | 23 ++++ package.json | 3 +- src/describe/index.ts | 8 +- src/index.ts | 5 +- src/structure/index.ts | 3 + src/structure/keyed.ts | 2 +- src/structure/object.ts | 18 +-- src/test/keyed.test.ts | 70 +++++++++++- src/utilityTypes.ts | 31 +++++- 21 files changed, 547 insertions(+), 71 deletions(-) create mode 100644 examples/__util/index.ts create mode 100644 examples/customSchema/index.ts create mode 100644 examples/customSchema/radians.ts create mode 100644 examples/genericEnumTypes/index.ts create mode 100644 examples/genericTypes/index.ts create mode 100644 examples/stateMachine/connection.ts create mode 100644 examples/stateMachine/graph.ts create mode 100644 examples/stateMachine/index.ts create mode 100644 examples/stateMachine/node.ts create mode 100644 examples/stateMachine/triggerCondition.ts diff --git a/README.md b/README.md index 6039014..fd98db0 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,15 @@ Gives tools to describe binary structures with full TypeScript support. Encodes - [Features](#features) - [Installation](#installation) - [Basic usage](#basic-usage) +- [Running examples](#running-examples) - [Defining schemas](#defining-schemas) - [Primitives](#primitives) - [Objects](#objects) - [Arrays](#arrays) - [Tuples](#tuples) - [Optionals](#optionals) -- [Recursive types](#recursive-types) + - [Recursive types](#recursive-types) +- [Custom schema types](#custom-schema-types) - [Serialization and Deserialization](#serialization-and-deserialization) # Features: @@ -36,6 +38,8 @@ Using NPM: $ npm i --save typed-binary ``` +# Requirements +To properly enable type inference, **TypeScript 4.5** and up is required because of it's newly added [Tail-Recursion Elimination on Conditional Types](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-5.html#tail-recursion-elimination-on-conditional-types) feature, # Basic usage ```ts @@ -104,6 +108,11 @@ async function saveGameState(state: GameState): Promise { } ``` +# Running examples +There are a handful of examples provided. To run any one of them make sure to clone the [typed-binary](https://github.com/iwoplaza/typed-binary) repository first, then go into the `examples/` directory. To setup the examples environment, run `npm run link`, which will build the parent project and link it to dependencies of the child 'examples' project. + +Pick an example that peaks interest, and run `npm run example:exampleName`. + # Defining schemas ## Primitives There's a couple primitives to choose from: @@ -164,7 +173,7 @@ This feature allows for the parsing of a type that contains different fields dep **Keyed by strings:** ```ts -import { BufferWriter, BufferReader, INT, STRING, generic, object } from 'typed-binary'; +import { BufferWriter, BufferReader, INT, STRING, BOOL, generic, object } from 'typed-binary'; // Generic object schema const Animal = generic({ @@ -218,6 +227,7 @@ else { // This would result in a type error (Static typing FTW!) // console.log(`Striped: ${animal.striped}`); } + ``` **Keyed by an enum (byte):** @@ -335,42 +345,48 @@ console.log(JSON.stringify(Person.read(reader).address)); // undefined console.log(JSON.stringify(Person.read(reader).address)); // { "city": "New York", ... } ``` -# Recursive types -If you want an object type to be able to contain one of itself (recursion), then you have to apply the following pattern: -```ts -import { INT, STRING, object, Parsed, ParsedConcrete, typedGeneric, typedObject, TypeToken } from 'typed-binary'; - -interface ExpressionBase {} +## Recursive types +If you want an object type to be able to contain one of itself (recursion), then you have to start using **keyed** types. The basic pattern is this: -interface MultiplyExpression extends ExpressionBase { - type: 'multiply'; - a: Expression; - b: Expression; -} - -interface NegateExpression extends ExpressionBase { - type: 'negate'; - inner: Expression; -} - -type IntLiteralExpression = ParsedConcrete; -const IntLiteralExpression = object({ +```ts +/** + * Wrapping a schema with a 'keyed' call allows the inner code to + * use a reference to the type we're currently creating, instead + * of the type itself. + * + * The reference variable 'Recursive' doesn't have to be called + * the same as the actual variable we're storing the schema in, + * but it's a neat trick that makes the schema code more readable. + * + * The 'recursive-key' has to uniquely identify this type in this tree. + * There may be other distinct types using the same key, as long as they do + * not interact with each other (one doesn't contain the other). + * This is because references are resolved recursively once the method + * passed as the 2nd argument to 'keyed' returns the schema. + */ +const Recursive = keyed('recursive-key', (Recursive) => object({ value: INT, -}); + next: optional(Recursive), +})) +``` -type Expression = MultiplyExpression|NegateExpression|IntLiteralExpression; -const Expression = typedGeneric(new TypeToken(), { - name: STRING, -}, { - 'multiply': typedObject(() => ({ +### Recursive types alongside generics +```ts +import { INT, STRING, object, keyed } from 'typed-binary'; + +type Expression = Parsed; +const Expression = keyed('expression', (Expression) => generic({}, { + 'multiply': object({ a: Expression, b: Expression, - })), - 'negate': typedObject(() => ({ + }), + 'negate': object({ inner: Expression, - })), - 'int_literal': IntLiteralExpression -}); + }), + 'int_literal': object({ + value: INT, + }), +})); const expr: Parsed = { type: 'multiply', @@ -389,6 +405,53 @@ const expr: Parsed = { ``` +# Custom schema types +Custom schema types can be defined. They are, under the hood, classes that extend the `Schema` base class. The generic `T` type represents what kind of data this schema serializes from and deserializes into. + +```ts +import { ISerialInput, ISerialOutput, Schema, IRefResolver } from 'typed-binary'; + +/** + * A schema storing radians with 2 bytes of precision. + */ +class RadiansSchema extends Schema { + resolve(ctx: IRefResolver): void { + // No inner references to resolve + } + + read(input: ISerialInput): number { + const low = input.readByte(); + const high = input.readByte(); + + const discrete = (high << 8) | low; + return discrete / 65535 * Math.PI; + } + + write(output: ISerialOutput, value: number): void { + // The value will be wrapped to be in range of [0, Math.PI) + const wrapped = ((value % Math.PI) + Math.PI) % Math.PI; + // Discretising the value to be ints in range of [0, 65535] + const discrete = Math.min(Math.floor(wrapped / Math.PI * 65535), 65535); + + const low = discrete & 0xFF; + const high = (discrete >> 8) & 0xFF; + + output.writeByte(low); + output.writeByte(high); + } + + sizeOf(_: number): number { + // The size of the data serialized by this schema + // doesn't depend on the actual value. It's always 2 bytes. + return 2; + } +} + +// Creating a singleton instance of the schema, +// since it has no configuration properties. +export const RADIANS = new RadiansSchema(); +``` + # Serialization and Deserialization Each schema has the following methods: ```ts diff --git a/examples/__util/index.ts b/examples/__util/index.ts new file mode 100644 index 0000000..729bedf --- /dev/null +++ b/examples/__util/index.ts @@ -0,0 +1,10 @@ +import { BufferWriter, BufferReader, Schema } from 'typed-binary'; + +export function writeAndRead(schema: Schema, value: T) { + const buffer = Buffer.alloc(schema.sizeOf(value)); + const writer = new BufferWriter(buffer); + const reader = new BufferReader(buffer); + + schema.write(writer, value); + return schema.read(reader); +} diff --git a/examples/customSchema/index.ts b/examples/customSchema/index.ts new file mode 100644 index 0000000..47f3ca7 --- /dev/null +++ b/examples/customSchema/index.ts @@ -0,0 +1,24 @@ +// +// Run with `npm run example:customSchema` +// + +import { Parsed, object } from 'typed-binary'; +import { writeAndRead } from '../__util'; +import { RADIANS } from './radians'; + +/* + * ROTATION + */ + +type Rotation = Parsed; +const Rotation = object({ + roll: RADIANS, + pitch: RADIANS, + yaw: RADIANS, +}); + +console.log(writeAndRead(Rotation, { + roll: -0.1, + pitch: 0.12345, + yaw: Math.PI + 1.12345, +})); diff --git a/examples/customSchema/radians.ts b/examples/customSchema/radians.ts new file mode 100644 index 0000000..e7d0361 --- /dev/null +++ b/examples/customSchema/radians.ts @@ -0,0 +1,39 @@ +import { ISerialInput, ISerialOutput, Schema, IRefResolver } from 'typed-binary'; + +/** + * A schema storing radians with 2 bytes of precision. + */ +class RadiansSchema extends Schema { + resolve(ctx: IRefResolver): void { + // No inner references to resolve + } + + read(input: ISerialInput): number { + const low = input.readByte(); + const high = input.readByte(); + + const discrete = (high << 8) | low; + return discrete / 65535 * Math.PI; + } + + write(output: ISerialOutput, value: number): void { + // The value will be wrapped to be in range of [0, Math.PI) + const wrapped = ((value % Math.PI) + Math.PI) % Math.PI; + // Discretising the value to be ints in range of [0, 65535] + const discrete = Math.min(Math.floor(wrapped / Math.PI * 65535), 65535); + + const low = discrete & 0xFF; + const high = (discrete >> 8) & 0xFF; + + output.writeByte(low); + output.writeByte(high); + } + + sizeOf(_: number): number { + // The size of the data serialized by this schema + // doesn't depend on the actual value. It's always 2 bytes. + return 2; + } +} + +export const RADIANS = new RadiansSchema(); diff --git a/examples/genericEnumTypes/index.ts b/examples/genericEnumTypes/index.ts new file mode 100644 index 0000000..80aa6a3 --- /dev/null +++ b/examples/genericEnumTypes/index.ts @@ -0,0 +1,61 @@ +// +// Run with `npm run example:genericEnumTypes` +// + +import { BufferWriter, BufferReader, INT, STRING, BOOL, genericEnum, object } from 'typed-binary'; + +enum AnimalType { + DOG = 0, + CAT = 1, +}; + +// Generic (enum) object schema +const Animal = genericEnum({ + nickname: STRING, + age: INT, +}, { + [AnimalType.DOG]: object({ // Animal can be a dog + breed: STRING, + }), + [AnimalType.CAT]: object({ // Animal can be a cat + striped: BOOL, + }), +}); + +// A buffer to serialize into/out of +const buffer = Buffer.alloc(16); +const writer = new BufferWriter(buffer); +const reader = new BufferReader(buffer); + +// Writing an Animal +Animal.write(writer, { + type: AnimalType.CAT, // We're specyfing which concrete type we want this object to be. + + // Base properties + nickname: 'James', + age: 5, + + // Concrete type specific properties + striped: true, +}); + +// Deserializing the animal +const animal = Animal.read(reader); + +// -- Type checking works here! -- +// animal.type => AnimalType +if (animal.type === AnimalType.CAT) { + // animal.type => AnimalType.CAT + console.log("It's a cat!"); + // animal.striped => bool + console.log(animal.striped ? "Striped" : "Not striped"); +} +else { + // animal.type => AnimalType.DOG + console.log("It's a dog!"); + // animal.breed => string + console.log(`More specifically, a ${animal.breed}`); + + // This would result in a type error (Static typing FTW!) + // console.log(`Striped: ${animal.striped}`); +} diff --git a/examples/genericTypes/index.ts b/examples/genericTypes/index.ts new file mode 100644 index 0000000..979ba52 --- /dev/null +++ b/examples/genericTypes/index.ts @@ -0,0 +1,58 @@ +// +// Run with `npm run example:genericTypes` +// + +import { BufferWriter, BufferReader, INT, STRING, BOOL, generic, object } from 'typed-binary'; + +// Generic object schema +const Animal = generic({ + nickname: STRING, + age: INT, +}, { + 'dog': object({ // Animal can be a dog + breed: STRING, + }), + 'cat': object({ // Animal can be a cat + striped: BOOL, + }), +}); + +// A buffer to serialize into/out of +const buffer = Buffer.alloc(16); +const writer = new BufferWriter(buffer); +const reader = new BufferReader(buffer); + +// Writing an Animal +Animal.write(writer, { + type: 'cat', // We're specyfing which concrete type we want this object to be. + + // Base properties + nickname: 'James', + age: 5, + + // Concrete type specific properties + striped: true, +}); + +// Deserializing the animal +const animal = Animal.read(reader); + +console.log(JSON.stringify(animal)); // { "age": 5, "striped": true ... } + +// -- Type checking works here! -- +// animal.type => 'cat' | 'dog' +if (animal.type === 'cat') { + // animal.type => 'cat' + console.log("It's a cat!"); + // animal.striped => bool + console.log(animal.striped ? "Striped" : "Not striped"); +} +else { + // animal.type => 'dog' + console.log("It's a dog!"); + // animal.breed => string + console.log(`More specifically, a ${animal.breed}`); + + // This would result in a type error (Static typing FTW!) + // console.log(`Striped: ${animal.striped}`); +} diff --git a/examples/package.json b/examples/package.json index 1c0b57c..36b8d29 100644 --- a/examples/package.json +++ b/examples/package.json @@ -1,9 +1,13 @@ { "name": "typed-binary-examples", "scripts": { - "link": "npm link ../", + "link": "cd .. && npm run build && cd examples/ && npm link ../", "example:binaryMesh": "ts-node binaryMesh/index.ts", - "example:recursiveTypes": "ts-node recursiveTypes/index.ts" + "example:recursiveTypes": "ts-node recursiveTypes/index.ts", + "example:genericTypes": "ts-node genericTypes/index.ts", + "example:genericEnumTypes": "ts-node genericEnumTypes/index.ts", + "example:stateMachine": "ts-node stateMachine/index.ts", + "example:customSchema": "ts-node customSchema/index.ts" }, "devDependencies": { "ts-node": "^10.4.0", diff --git a/examples/recursiveTypes/index.ts b/examples/recursiveTypes/index.ts index 22f6a3d..e96117c 100644 --- a/examples/recursiveTypes/index.ts +++ b/examples/recursiveTypes/index.ts @@ -2,19 +2,42 @@ // Run with `npm run example:recursiveTypes` // -import type { Parsed } from 'typed-binary'; -import { STRING, keyed, object, optional } from 'typed-binary'; +import { Parsed } from 'typed-binary'; +import { INT, STRING, keyed, generic, object, optional } from 'typed-binary'; type Expression = Parsed; -const Expression = keyed('expression' as const, (Expression) => object({ - bruh: STRING, - inner: optional(Expression), +const Expression = keyed('expression', (Expression) => generic({}, { + 'multiply': object({ + a: Expression, + b: Expression, + }), + 'negate': object({ + inner: Expression, + }), + 'int_literal': object({ + value: INT, + }), })); +const expr: Parsed = { + type: 'multiply', + a: { + type: 'negate', + inner: { + type: 'int_literal', + value: 15, + } + }, + b: { + type: 'int_literal', + value: 2, + }, +}; + type Instruction = Parsed; const Instruction = object({ - some: STRING, - inner: optional(Expression), + target_variable: STRING, + expression: optional(Expression), }); type Complex = Parsed; @@ -27,17 +50,9 @@ const Complex = keyed('complex' as const, (Complex) => object({ })), })); -const expr: Expression = { - bruh: 'firstLevel', - inner: { - bruh: 'hello', - inner: undefined, - }, -}; - const inst: Instruction = { - some: 'firstlevel', - inner: undefined, + target_variable: 'firstlevel', + expression: undefined, }; const complex: Complex = { diff --git a/examples/stateMachine/connection.ts b/examples/stateMachine/connection.ts new file mode 100644 index 0000000..f731234 --- /dev/null +++ b/examples/stateMachine/connection.ts @@ -0,0 +1,24 @@ +import { BYTE, FLOAT, INT, object } from 'typed-binary'; +import { TriggerCondition } from './triggerCondition'; + + +export const ConnectionTemplate = + object({ + targetNodeIndex: INT, + /** + * The duration of the transition in Minecraft ticks + */ + transitionDuration: FLOAT, + transitionEasing: BYTE, + triggerCondition: TriggerCondition, + }); + +export enum Easing { + LINEAR = 0, + EASE_IN_QUAD = 1, + EASE_OUT_QUAD = 2, + EASE_IN_OUT_QUAD = 3, + EASE_IN_CUBIC = 4, + EASE_OUT_CUBIC = 5, + EASE_IN_OUT_SINE = 6, +} \ No newline at end of file diff --git a/examples/stateMachine/graph.ts b/examples/stateMachine/graph.ts new file mode 100644 index 0000000..fc9049b --- /dev/null +++ b/examples/stateMachine/graph.ts @@ -0,0 +1,9 @@ +import { INT, arrayOf, object } from 'typed-binary'; +import { NodeTemplate } from './node'; + + +export const Graph = + object({ + entryNode: INT, + nodes: arrayOf(NodeTemplate), + }); diff --git a/examples/stateMachine/index.ts b/examples/stateMachine/index.ts new file mode 100644 index 0000000..1afc8df --- /dev/null +++ b/examples/stateMachine/index.ts @@ -0,0 +1,34 @@ +// +// Run with `npm run example:stateMachine` +// + +import { Parsed } from 'typed-binary'; +import { Easing } from './connection'; +import { MobState } from './triggerCondition'; +import { Graph } from './graph'; + + +const graph: Parsed = { + entryNode: 0, + nodes: [ + { + animationKey: '', + looping: false, + playbackSpeed: 1, + startFrame: 0, + connections: [ + { + targetNodeIndex: 1, + transitionDuration: 3, + transitionEasing: Easing.EASE_IN_OUT_QUAD, + triggerCondition: { + type: 'core:state', + state: MobState.MOVING_HORIZONTALLY, + }, + } + ] + } + ], +}; + +console.log(graph); diff --git a/examples/stateMachine/node.ts b/examples/stateMachine/node.ts new file mode 100644 index 0000000..8dd6024 --- /dev/null +++ b/examples/stateMachine/node.ts @@ -0,0 +1,12 @@ +import { BOOL, FLOAT, INT, STRING, arrayOf, object } from 'typed-binary'; +import { ConnectionTemplate } from './connection'; + + +export const NodeTemplate = + object({ + animationKey: STRING, + startFrame: INT, + playbackSpeed: FLOAT, + looping: BOOL, + connections: arrayOf(ConnectionTemplate), + }); diff --git a/examples/stateMachine/triggerCondition.ts b/examples/stateMachine/triggerCondition.ts new file mode 100644 index 0000000..929c7e6 --- /dev/null +++ b/examples/stateMachine/triggerCondition.ts @@ -0,0 +1,23 @@ +import { BYTE, generic, keyed, object } from 'typed-binary'; +import type { Parsed } from 'typed-binary'; + + +type TriggerCondition = Parsed; +export const TriggerCondition = keyed('trigger-condition', (TriggerCondition) => generic({}, { + 'core:state': object({ + state: BYTE, + }), + 'core:animation_finished': object({}), + 'core:not': object({ + condition: TriggerCondition, + }), +})); + + +export enum MobState { + ON_GROUND = 0, + AIRBORNE = 1, + STANDING_STILL = 2, + MOVING_HORIZONTALLY = 3, +} + diff --git a/package.json b/package.json index b74f324..aeb3dcf 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "prepublishOnly": "npm run lint && npm run build", "dryPublish": "npm publish --dry-run", "test": "cross-env TS_NODE_PROJECT=\"tsconfig.test.json\" mocha --reporter spec --require ts-node/register src/**/*.test.ts", - "test-single": "cross-env TS_NODE_PROJECT=\"tsconfig.test.json\" mocha --reporter spec --require ts-node/register" + "test-single": "cross-env TS_NODE_PROJECT=\"tsconfig.test.json\" mocha --reporter spec --require ts-node/register", + "ts-version": "tsc -v" }, "keywords": [ "typescript", diff --git a/src/describe/index.ts b/src/describe/index.ts index 7a85f9c..2be1706 100644 --- a/src/describe/index.ts +++ b/src/describe/index.ts @@ -10,18 +10,18 @@ import { KeyedSchema } from '../structure/keyed'; export const chars = (length: T) => new CharsSchema(length); -export const object =

(properties: SchemaMap

) => +export const object =

= Record>(properties: SchemaMap

) => new ObjectSchema(properties); -export const generic =

, S extends {[Key in keyof S]: ISchemaWithProperties>}>(properties: SchemaMap

, subTypeMap: S) => - new GenericObjectSchema( +export const generic =

= Record, S extends {[Key in keyof S]: ISchemaWithProperties>} = Record>(properties: SchemaMap

, subTypeMap: S) => + new GenericObjectSchema( SubTypeKey.STRING, properties, subTypeMap ); export const genericEnum =

, S extends {[Key in keyof S]: ISchemaWithProperties>}>(properties: SchemaMap

, subTypeMap: S) => - new GenericObjectSchema( + new GenericObjectSchema( SubTypeKey.ENUM, properties, subTypeMap diff --git a/src/index.ts b/src/index.ts index ad92178..b949db2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ export * from './structure'; export * from './describe'; export * from './io'; -export type { Parsed } from './utilityTypes'; \ No newline at end of file + +import type { ExpandRecursively, Parsed as ParsedRaw } from './utilityTypes'; + +export type Parsed> = ExpandRecursively>; diff --git a/src/structure/index.ts b/src/structure/index.ts index 37bc40c..20bdef8 100644 --- a/src/structure/index.ts +++ b/src/structure/index.ts @@ -6,6 +6,9 @@ export { STRING, Ref, + IRefResolver, + Schema, + ISchemaWithProperties, Keyed, KeyedSchema, ObjectSchema, diff --git a/src/structure/keyed.ts b/src/structure/keyed.ts index 40b77df..548e033 100644 --- a/src/structure/keyed.ts +++ b/src/structure/keyed.ts @@ -57,7 +57,7 @@ class RefResolve implements IRefResolver { } } -export class KeyedSchema> implements ISchema> { +export class KeyedSchema> implements ISchema> { public readonly _infered!: Keyed; public innerType: S; diff --git a/src/structure/object.ts b/src/structure/object.ts index 011b8a4..fd66620 100644 --- a/src/structure/object.ts +++ b/src/structure/object.ts @@ -57,17 +57,19 @@ export class ObjectSchema extends Schema sizeOf(value: I): number { return exactEntries(this.properties) .map(([key, property]) => property.sizeOf(value[key])) // Mapping properties into their sizes. - .reduce((a, b) => a + b); // Summing them up + .reduce((a, b) => a + b, 0); // Summing them up } } export type AsSubTypes = ({[K in keyof T]: T[K] extends ISchemaWithProperties ? P & { type: K } : never})[keyof T]; export type StabilizedMap = ({[K in keyof T]: T[K] extends ISchemaWithProperties ? ObjectSchema

: never}); +type GenericInfered = T extends Record ? AsSubTypes : T & AsSubTypes; + export class GenericObjectSchema< T extends Record, // Base properties E extends {[key in keyof E]: ISchemaWithProperties>}, // Sub type map -> extends Schema> { +> extends Schema> { private _baseObject: ObjectSchema; public subTypeMap: StabilizedMap; @@ -90,7 +92,7 @@ export class GenericObjectSchema< this.subTypeMap = resolveMap(ctx, this._subTypeMap); } - write(output: ISerialOutput, value: T & AsSubTypes): void { + write(output: ISerialOutput, value: GenericInfered): void { // Figuring out sub-types const subTypeDescription = this.subTypeMap[value.type] || null; @@ -107,7 +109,7 @@ export class GenericObjectSchema< } // Writing the base properties - this._baseObject.write(output, value); + this._baseObject.write(output, value as T); // Extra sub-type fields for (const [key, extraProp] of exactEntries(subTypeDescription.properties)) { @@ -115,7 +117,7 @@ export class GenericObjectSchema< } } - read(input: ISerialInput): T & AsSubTypes { + read(input: ISerialInput): GenericInfered { const subTypeKey = this.keyedBy === SubTypeKey.ENUM ? input.readByte() : input.readString(); const subTypeDescription = this.subTypeMap[subTypeKey as keyof E] || null; @@ -123,7 +125,7 @@ export class GenericObjectSchema< throw new Error(`Unknown sub-type '${subTypeKey}' in among '${JSON.stringify(Object.keys(this.subTypeMap))}'`); } - const result = this._baseObject.read(input) as T & AsSubTypes; + const result = this._baseObject.read(input) as GenericInfered; // Making the sub type key available to the result object. result.type = subTypeKey as keyof E; @@ -138,8 +140,8 @@ export class GenericObjectSchema< return result; } - sizeOf(value: T & AsSubTypes): number { - let size = this._baseObject.sizeOf(value); + sizeOf(value: GenericInfered): number { + let size = this._baseObject.sizeOf(value as T); // We're a generic object trying to encode a concrete value. size += this.keyedBy === SubTypeKey.ENUM ? 1 : STRING.sizeOf(value.type as string); diff --git a/src/test/keyed.test.ts b/src/test/keyed.test.ts index 1251c15..5d90876 100644 --- a/src/test/keyed.test.ts +++ b/src/test/keyed.test.ts @@ -1,7 +1,7 @@ import * as chai from 'chai'; import { encodeAndDecode } from './_mock.test'; import { INT, STRING } from '../structure/baseTypes'; -import { keyed, object, generic, optional } from '../describe'; +import { keyed, object, generic, genericEnum, optional } from '../describe'; import { Parsed } from '..'; const expect = chai.expect; @@ -178,4 +178,72 @@ describe('KeyedSchema', () => { const decoded = encodeAndDecode(Example, value); expect(decoded).to.deep.equal(value); }); + + it('should encode and decode a keyed enum generic object, no base props, with references', () => { + type Example = Parsed; + const Example = keyed('example', (Example) => genericEnum({}, { + 0: object({ + next: optional(Example), + }), + 1: object({ + left: optional(Example), + right: optional(Example), + }) + })); + + const value: Example = { + type: 0, + next: { + type: 1, + left: { + type: 0, + next: undefined, + }, + right: { + type: 0, + next: undefined, + }, + }, + }; + + const decoded = encodeAndDecode(Example, value); + expect(decoded).to.deep.equal(value); + }); + + it('should encode and decode a keyed enum generic object, with references', () => { + type Example = Parsed; + const Example = keyed('example', (Example) => genericEnum({ + label: STRING, + }, { + 0: object({ + next: optional(Example), + }), + 1: object({ + left: optional(Example), + right: optional(Example), + }) + })); + + const value: Example = { + label: 'Root', + type: 0, + next: { + label: 'Level 1', + type: 1, + left: { + label: 'Level 2-A', + type: 0, + next: undefined, + }, + right: { + label: 'Level 2-B', + type: 0, + next: undefined, + }, + }, + }; + + const decoded = encodeAndDecode(Example, value); + expect(decoded).to.deep.equal(value); + }); }); diff --git a/src/utilityTypes.ts b/src/utilityTypes.ts index 57ca82d..5d9b10d 100644 --- a/src/utilityTypes.ts +++ b/src/utilityTypes.ts @@ -1,21 +1,44 @@ import { ISchema, Keyed, Ref } from './structure/types'; -export type Parsed> = +export type Parsed> = T extends Keyed ? - Parsed : + Parsed : T extends Ref ? (K extends keyof M ? Parsed : never) : T extends ISchema ? Parsed : T extends Record ? - {[K in keyof T]: Parsed} : + { [K in keyof T]: Parsed } : + T extends (infer E)[] ? + (Parsed)[] : T; export type ValueOrProvider = T | (() => T); type UndefinedKeys = { - [P in keyof T]: undefined extends T[P] ? P: never + [P in keyof T]: undefined extends T[P] ? P : never }[keyof T] export type OptionalUndefined = Partial>> & Omit>; + +export type Expand = + T extends (...args: infer A) => infer R + ? (...args: Expand) => Expand : + T extends Record & infer O // Getting rid of Record-s, which represent empty objects. + ? O : + T extends infer O + ? { [K in keyof O]: O[K] } : + never; + +export type ExpandRecursively = + T extends (...args: infer A) => infer R + ? (...args: ExpandRecursively) => ExpandRecursively : + T extends Record & infer O // Getting rid of Record-s, which represent empty objects. + ? ExpandRecursively : + T extends object + ? (T extends infer O + ? { [K in keyof O]: ExpandRecursively } : + never + ) : + T;