diff --git a/README.md b/README.md index 96f4392..6039014 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Gives tools to describe binary structures with full TypeScript support. Encodes - [Arrays](#arrays) - [Tuples](#tuples) - [Optionals](#optionals) +- [Recursive types](#recursive-types) - [Serialization and Deserialization](#serialization-and-deserialization) # Features: @@ -334,6 +335,60 @@ 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 {} + +interface MultiplyExpression extends ExpressionBase { + type: 'multiply'; + a: Expression; + b: Expression; +} + +interface NegateExpression extends ExpressionBase { + type: 'negate'; + inner: Expression; +} + +type IntLiteralExpression = ParsedConcrete<ExpressionBase, typeof IntLiteralExpression, 'int_literal'>; +const IntLiteralExpression = object({ + value: INT, +}); + +type Expression = MultiplyExpression|NegateExpression|IntLiteralExpression; +const Expression = typedGeneric(new TypeToken<Expression>(), { + name: STRING, +}, { + 'multiply': typedObject<MultiplyExpression>(() => ({ + a: Expression, + b: Expression, + })), + 'negate': typedObject<NegateExpression>(() => ({ + inner: Expression, + })), + 'int_literal': IntLiteralExpression +}); + +const expr: Parsed<typeof Expression> = { + type: 'multiply', + a: { + type: 'negate', + inner: { + type: 'int_literal', + value: 15, + } + }, + b: { + type: 'int_literal', + value: 2, + }, +}; + +``` + # Serialization and Deserialization Each schema has the following methods: ```ts diff --git a/examples/package.json b/examples/package.json index 8908524..1c0b57c 100644 --- a/examples/package.json +++ b/examples/package.json @@ -2,7 +2,8 @@ "name": "typed-binary-examples", "scripts": { "link": "npm link ../", - "example:binaryMesh": "ts-node binaryMesh/index.ts" + "example:binaryMesh": "ts-node binaryMesh/index.ts", + "example:recursiveTypes": "ts-node recursiveTypes/index.ts" }, "devDependencies": { "ts-node": "^10.4.0", diff --git a/examples/recursiveTypes/index.ts b/examples/recursiveTypes/index.ts new file mode 100644 index 0000000..a1083f1 --- /dev/null +++ b/examples/recursiveTypes/index.ts @@ -0,0 +1,53 @@ +// +// Run with `npm run example:recursiveTypes` +// + +import { INT, object, Parsed, ParsedConcrete, STRING, typedGeneric, typedObject, TypeToken } from 'typed-binary'; + +interface ExpressionBase {} + +interface MultiplyExpression extends ExpressionBase { + type: 'multiply'; + a: Expression; + b: Expression; +} + +interface NegateExpression extends ExpressionBase { + type: 'negate'; + inner: Expression; +} + +type IntLiteralExpression = ParsedConcrete<ExpressionBase, typeof IntLiteralExpression, 'int_literal'>; +const IntLiteralExpression = object({ + value: INT, +}); + +type Expression = MultiplyExpression|NegateExpression|IntLiteralExpression; +const Expression = typedGeneric(new TypeToken<Expression>(), { + name: STRING, +}, { + 'multiply': typedObject<MultiplyExpression>(() => ({ + a: Expression, + b: Expression, + })), + 'negate': typedObject<NegateExpression>(() => ({ + inner: Expression, + })), + 'int_literal': IntLiteralExpression +}); + +const expr: Parsed<typeof Expression> = { + type: 'multiply', + a: { + type: 'negate', + inner: { + type: 'int_literal', + value: 15, + } + }, + b: { + type: 'int_literal', + value: 2, + }, +}; + diff --git a/package-lock.json b/package-lock.json index 2ad98a9..9ddfd4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "typed-binary", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "typed-binary", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@rollup/plugin-commonjs": "^21.0.1", diff --git a/package.json b/package.json index 60d1d2f..bfed66b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typed-binary", - "version": "1.0.0", + "version": "1.1.0", "description": "Describe binary structures with full TypeScript support. Encode and decode into pure JavaScript objects.", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/src/describe/index.ts b/src/describe/index.ts index f2986ce..9109e59 100644 --- a/src/describe/index.ts +++ b/src/describe/index.ts @@ -3,17 +3,20 @@ import { ArraySchema } from '../structure/array'; import { OptionalSchema } from '../structure/optional'; import { GenericObjectSchema } from '../structure/object'; import { TupleSchema } from '../structure/tuple'; -import { Schema, SchemaProperties } from '../structure/types'; -import { ValueOrProvider } from '../utilityTypes'; - +import { ISchema, SchemaProperties } from '../structure/types'; +import { OptionalUndefined, ValueOrProvider } from '../utilityTypes'; +import { Parsed } from '..'; export const chars = <T extends number>(length: T) => new CharsSchema(length); -export const object = <P extends SchemaProperties>(properties: P) => +export const object = <P extends SchemaProperties>(properties: ValueOrProvider<P>) => new ObjectSchema(properties); +export const typedObject = <P extends {[key in keyof P]: P[key]}>(properties: ValueOrProvider<unknown>) => + new ObjectSchema<any, OptionalUndefined<P>>(properties); + export const generic = <P extends SchemaProperties, S extends {[key in keyof S]: ObjectSchema<any>}>(properties: P, subTypeMap: ValueOrProvider<S>) => new GenericObjectSchema( SubTypeKey.STRING as any, @@ -21,6 +24,16 @@ export const generic = <P extends SchemaProperties, S extends {[key in keyof S]: subTypeMap ); +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export class TypeToken<I> {} + +export const typedGeneric = <P extends {[key in keyof P]: P[key]} & { type: string }>(token: TypeToken<P>, properties: ValueOrProvider<unknown>, subTypeMap: any) => + new GenericObjectSchema<any, any, SubTypeKey.STRING, P>( + SubTypeKey.STRING, + properties, + subTypeMap + ); + export const genericEnum = <P extends SchemaProperties, S extends {[key in keyof S]: ObjectSchema<any>}>(properties: P, subTypeMap: ValueOrProvider<S>) => new GenericObjectSchema( SubTypeKey.ENUM as any, @@ -28,11 +41,11 @@ export const genericEnum = <P extends SchemaProperties, S extends {[key in keyof subTypeMap ); -export const arrayOf = <T extends Schema<T['_infered']>>(elementType: T) => +export const arrayOf = <T extends ISchema<Parsed<T>>>(elementType: T) => new ArraySchema(elementType); -export const tupleOf = <T extends Schema<T['_infered']>>(elementType: T, length: number) => +export const tupleOf = <T extends ISchema<Parsed<T>>>(elementType: T, length: number) => new TupleSchema(elementType, length); -export const optional = <I, T extends Schema<I>>(innerType: T) => +export const optional = <T extends ISchema<Parsed<T>>>(innerType: T) => new OptionalSchema(innerType); diff --git a/src/index.ts b/src/index.ts index ad92178..2a6d5b4 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 } from './utilityTypes'; \ No newline at end of file +export type { Parsed, ParsedConcrete } from './utilityTypes'; \ No newline at end of file diff --git a/src/parsed.test.ts b/src/parsed.test.ts index 85e42a2..40720ff 100644 --- a/src/parsed.test.ts +++ b/src/parsed.test.ts @@ -40,4 +40,4 @@ export const KeyframeNodeTemplate = }); type KeyframeNodeTemplate = Parsed<typeof KeyframeNodeTemplate>; -// const s = {} as KeyframeNodeTemplate; +// const s = {} as KeyframeNodeTemplate; \ No newline at end of file diff --git a/src/structure/object.ts b/src/structure/object.ts index a97911a..f3fae3c 100644 --- a/src/structure/object.ts +++ b/src/structure/object.ts @@ -1,5 +1,6 @@ +import { Parsed } from '..'; import type { ISerialInput, ISerialOutput } from '../io'; -import type { ValueOrProvider } from '../utilityTypes'; +import type { OptionalUndefined, ValueOrProvider } from '../utilityTypes'; import { STRING } from './baseTypes'; import { Schema, InferedProperties, SchemaProperties @@ -7,16 +8,28 @@ import { import { SubTypeKey } from './types'; -export class ObjectSchema<T extends SchemaProperties, O extends InferedProperties<T> = InferedProperties<T>> extends Schema<O> { - constructor(public readonly properties: T) { +export class ObjectSchema< + T extends SchemaProperties, + O extends OptionalUndefined<InferedProperties<T>> = OptionalUndefined<InferedProperties<T>> +> extends Schema<O> { + private cachedProperties?: T; + + constructor(private readonly _properties: ValueOrProvider<T>) { super(); } + public get properties() { + return this.cachedProperties || ( + this.cachedProperties = (typeof this._properties === 'function' ? this._properties() : this._properties) + ); + } + write(output: ISerialOutput, value: O): void { const keys: string[] = Object.keys(this.properties); for (const key of keys) { - this.properties[key].write(output, value[key]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.properties[key].write(output, (value as any)[key]); } } @@ -25,8 +38,11 @@ export class ObjectSchema<T extends SchemaProperties, O extends InferedPropertie const result = {} as O; for (const key of keys) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - result[key] = this.properties[key].read(input) as O[typeof key]; + 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; + } } return result; @@ -37,15 +53,16 @@ export class ObjectSchema<T extends SchemaProperties, O extends InferedPropertie // Going through the base properties size += Object.keys(this.properties) - .map(key => this.properties[key].sizeOf(value[key])) // Mapping properties into their sizes. + // 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. .reduce((a, b) => a + b); // Summing them up return size; } } -type InferedSubTypes<T extends {[key in keyof T]: ObjectSchema<SchemaProperties>}> = { - [Key in keyof T]: T[Key]['_infered'] & { type: Key } +export type InferedSubTypes<T extends {[key in keyof T]: ObjectSchema<SchemaProperties>}> = { + [Key in keyof T]: Parsed<T[Key]> & { type: Key } }; export type ObjectSchemaMap<S, SI extends {[key in keyof SI]: SI[key]}> = {[key in keyof S]: ObjectSchema<SI[key]>}; @@ -53,11 +70,12 @@ export type ObjectSchemaMap<S, SI extends {[key in keyof SI]: SI[key]}> = {[key export class GenericObjectSchema< T extends SchemaProperties, // Base properties S extends {[Key in keyof S]: ObjectSchema<SchemaProperties>}, // Sub type map - K extends ((keyof S) extends string ? SubTypeKey.STRING : SubTypeKey.ENUM) -> extends ObjectSchema<T, InferedProperties<T> & InferedSubTypes<S>[keyof S]> { + K extends string|number, + I extends OptionalUndefined<InferedProperties<T>> & InferedSubTypes<S>[keyof S] = OptionalUndefined<InferedProperties<T>> & InferedSubTypes<S>[keyof S] +> extends ObjectSchema<T, I> { constructor( public readonly keyedBy: K, - properties: T, + properties: ValueOrProvider<T>, private readonly subTypeMap: ValueOrProvider<S> ) { super(properties); @@ -67,7 +85,7 @@ export class GenericObjectSchema< return typeof this.subTypeMap === 'function' ? this.subTypeMap() : this.subTypeMap; } - write(output: ISerialOutput, value: InferedProperties<T> & InferedSubTypes<S>[keyof S]): void { + write(output: ISerialOutput, value: I): void { // Figuring out sub-types const subTypeDescription = this.getSubTypeMap()[value.type] || null; if (subTypeDescription === null) { @@ -97,7 +115,7 @@ export class GenericObjectSchema< } } - read(input: ISerialInput): InferedProperties<T> & InferedSubTypes<S>[keyof S] { + read(input: ISerialInput): I { const subTypeMap = this.getSubTypeMap(); const subTypeKey = this.keyedBy === SubTypeKey.ENUM ? input.readByte() : input.readString(); @@ -116,15 +134,18 @@ export class GenericObjectSchema< for (const key of extraKeys) { const prop = (subTypeDescription.properties)[key]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (result as any)[key] = prop.read(input); + const value = prop.read(input); + if (value !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (result as any)[key] = value; + } } } return result; } - sizeOf(value: InferedProperties<T> & InferedSubTypes<S>[keyof S]): number { + sizeOf(value: I): number { let size = super.sizeOf(value); // We're a generic object trying to encode a concrete value. diff --git a/src/structure/types.ts b/src/structure/types.ts index e3435a0..d92b79b 100644 --- a/src/structure/types.ts +++ b/src/structure/types.ts @@ -1,22 +1,13 @@ +import { Parsed } from '..'; import { ISerialInput, ISerialOutput } from '../io'; export interface ISchema<P> { - /** - * Used as a type holder, so that type inference works correctly. - */ - readonly _infered: P; - write(output: ISerialOutput, value: P): void; read(input: ISerialInput): P; sizeOf(value: P): number; } export abstract class Schema<P> implements ISchema<P> { - /** - * Used as a type holder, so that type inference works correctly. - */ - readonly _infered!: P; - abstract write(output: ISerialOutput, value: P): void; abstract read(input: ISerialInput): P; abstract sizeOf(value: P): number; @@ -31,9 +22,9 @@ export enum SubTypeKey { ENUM = 'ENUM', } -// export type SchemaProperties<T> = T extends {[key in keyof T]: Schema<any>} ? {[key in keyof T]: Schema<T[key]['_infered']>} : never; +// export type SchemaProperties<T> = T extends {[key in keyof T]: Schema<any>} ? {[key in keyof T]: Schema<Parsed<T[key]>>} : never; export type SchemaProperties = {[key: string]: Schema<unknown>}; -export type InferedProperties<T extends {[key: string]: Schema<unknown>}> = {[key in keyof T]: T[key]['_infered']}; +export type InferedProperties<T extends {[key: string]: Schema<unknown>}> = {[key in keyof T]: Parsed<T[key]>}; export interface IConcreteObjectSchema<T extends SchemaProperties> extends ISchema<InferedProperties<T>> { readonly properties: T; diff --git a/src/test/array.test.ts b/src/test/array.test.ts index 0ce00c2..0daa82b 100644 --- a/src/test/array.test.ts +++ b/src/test/array.test.ts @@ -5,7 +5,7 @@ import { ArraySchema, INT } from '../structure'; const expect = chai.expect; -describe('(read/write)Array', () => { +describe('ArrayScheme', () => { it('should encode and decode a simple int array', () => { const length = randIntBetween(0, 5); const value = []; diff --git a/src/test/object.test.ts b/src/test/object.test.ts index 6aa0085..a9726ea 100644 --- a/src/test/object.test.ts +++ b/src/test/object.test.ts @@ -1,7 +1,7 @@ import * as chai from 'chai'; -import { makeIO } from './_mock.test'; +import { encodeAndDecode, makeIO } from './_mock.test'; import { INT, STRING } from '../structure/baseTypes'; -import { generic, genericEnum, object } from '../describe'; +import { generic, genericEnum, object, optional, typedObject, typedGeneric, TypeToken } from '../describe'; import { Parsed } from '../utilityTypes'; const expect = chai.expect; @@ -23,6 +23,26 @@ describe('ObjectSchema', () => { expect(description.read(input)).to.deep.equal(value); }); + it('should treat undefined properties as optional', () => { + 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); + }); + it('should encode and decode a generic object', () => { const genericDescription = generic({ @@ -86,8 +106,65 @@ describe('ObjectSchema', () => { const { output, input } = makeIO(schema.sizeOf(value)); schema.write(output, value); - expect(input.readInt() === 1); // a - expect(input.readInt() === 3); // c - expect(input.readInt() === 2); // b + expect(input.readInt()).to.equal(1); // a + 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<Explicit>(() => ({ + 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(new TypeToken<Explicit>(), { + 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); }); }); diff --git a/src/utilityTypes.ts b/src/utilityTypes.ts index f7ee3b0..a8d96c3 100644 --- a/src/utilityTypes.ts +++ b/src/utilityTypes.ts @@ -1,4 +1,11 @@ -import { Schema } from './structure/types'; +import { ISchema } from './structure/types'; -export type Parsed<T extends Schema<T['_infered']>> = T['_infered']; -export type ValueOrProvider<T> = T | (() => T); \ No newline at end of file +export type Parsed<T> = T extends ISchema<infer I> ? I : never; +export type ParsedConcrete<B, T, ConcreteType extends string> = B & Parsed<T> & { type: ConcreteType }; +export type ValueOrProvider<T> = T | (() => T); + +type UndefinedKeys<T> = { + [P in keyof T]: undefined extends T[P] ? P: never +}[keyof T] + +export type OptionalUndefined<T> = Partial<Pick<T, UndefinedKeys<T>>> & Omit<T, UndefinedKeys<T>>; diff --git a/tsconfig.json b/tsconfig.json index 14bab36..63a06dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -76,7 +76,7 @@ /* Type Checking */ "strict": true, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ - // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ @@ -85,7 +85,7 @@ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ @@ -98,5 +98,5 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, - "exclude": ["examples", "**/*.test.ts", "dist"] + "exclude": ["examples", "dist"] } diff --git a/tsconfig.test.json b/tsconfig.test.json index 2eb41b6..7939a73 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -76,7 +76,7 @@ /* Type Checking */ "strict": true, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ - // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ @@ -85,7 +85,7 @@ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ @@ -97,5 +97,8 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + "include": [ + "**/*.test.ts" + ], }