Skip to content

Commit

Permalink
Merge branch 'feature/better-recursive'
Browse files Browse the repository at this point in the history
  • Loading branch information
iwoplaza committed May 8, 2022
2 parents c40ade9 + 2043540 commit 6557e55
Show file tree
Hide file tree
Showing 31 changed files with 1,092 additions and 297 deletions.
124 changes: 118 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -104,6 +108,11 @@ async function saveGameState(state: GameState): Promise<void> {
}
```

# 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:
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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):**
Expand Down Expand Up @@ -335,10 +345,112 @@ 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:
> CURRENTLY WORKING ON A NEW PATTERN.
> THIS IS A WORK IN PROGRESS.
## 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:

```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),
}))
```

### Recursive types alongside generics
```ts
import { INT, STRING, object, keyed } from 'typed-binary';

type Expression = Parsed<typeof 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<typeof Expression> = {
type: 'multiply',
a: {
type: 'negate',
inner: {
type: 'int_literal',
value: 15,
}
},
b: {
type: 'int_literal',
value: 2,
},
};

```

# Custom schema types
Custom schema types can be defined. They are, under the hood, classes that extend the `Schema<T>` 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<number> {
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:
Expand Down
10 changes: 10 additions & 0 deletions examples/__util/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { BufferWriter, BufferReader, Schema } from 'typed-binary';

export function writeAndRead<T>(schema: Schema<T>, 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);
}
24 changes: 24 additions & 0 deletions examples/customSchema/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Rotation>;
const Rotation = object({
roll: RADIANS,
pitch: RADIANS,
yaw: RADIANS,
});

console.log(writeAndRead(Rotation, {
roll: -0.1,
pitch: 0.12345,
yaw: Math.PI + 1.12345,
}));
39 changes: 39 additions & 0 deletions examples/customSchema/radians.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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();
61 changes: 61 additions & 0 deletions examples/genericEnumTypes/index.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
58 changes: 58 additions & 0 deletions examples/genericTypes/index.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
8 changes: 6 additions & 2 deletions examples/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading

0 comments on commit 6557e55

Please sign in to comment.