npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

typed-binary

v4.0.0

Published

Describe binary structures with full TypeScript support. Encode and decode into pure JavaScript objects.

Downloads

204

Readme

Typed Binary

Gives tools to describe binary structures with full TypeScript support. Encodes and decodes into pure JavaScript objects, while giving type context for the parsed data.

Prioritising Developer Experience

Serialise and deserialise typed schemas without the need for redundant interfaces or an external DSL. Schemas themselves define what type they encode and decode, and the IDE knows it!

Basic Type and Documentation Inference

Above is a self-contained code snippet using typed-binary. The IDE can properly infer what Dog is.

Highlight feature

The feature I am most proud of would have to be recursive types. I wasn't sure it it would be possible to achieve without additional tooling, but pushing the TypeScript type inference engine to it's extremes paid off.

Table of contents

Features:

Why Typed Binary over other libraries?

  • It's one of the few libraries (if not the only one) with fully staticly-typed binary schemas.
  • Since value types are inferred from the schemas themselves, there is a single source-of-truth.
  • No external DSL necessary to define the schemas, meaning you have instant feedback without the need to compile the interface definitions.
  • It has zero-dependencies.
  • It's platform independent (use it in Node.js as well as in in Browsers)
  • While being made with TypeScript in mind, it also works with plain JavaScript.

Instalation

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 feature,

Basic usage

import {
  Parsed,
  object,
  dynamicArrayOf,
  i32,
  string,
  bool,
} from 'typed-binary';

const GameState = object({
  nickname: string, // Variable-length string
  stage: i32, // 32-bit integer
  newGamePlus: bool, // byte-encoded boolean
  collectables: dynamicArrayOf(string), // Variable-length string array
  powerUpgrades: object({
    // Nested object
    health: bool,
    strength: bool,
  }),
});

// Type alias for ease-of-use
type GameState = Parsed<typeof GameState>;

//...

import { BufferReader, BufferWriter } from 'typed-binary';

/**
 * Responsible for retrieving the saved game state.
 * If none can be found, returns a default starting state.
 */
async function loadGameState(): Promise<GameState> {
  try {
    const buffer = await fs.readFile('./savedState.bin');
    const reader = new BufferReader(buffer);

    return GameState.read(reader);
  } catch (e) {
    // Returning the default state if no saved state found.
    return {
      nickname: 'Default',
      stage: 1,
      newGamePlus: false,
      collectables: [],
      powerUpgrades: {
        health: false,
        strength: false,
      },
    };
  }
}

/**
 * Saves the game's state for future use.
 * @param state The state to save.
 */
async function saveGameState(state: GameState): Promise<void> {
  try {
    const buffer = Buffer.alloc(GameState.measure(state).size);
    const writer = new BufferWriter(buffer);

    GameState.write(writer, state);
    await fs.writeFile('./savedState.bin', buffer);
  } catch (e) {
    console.error(`Error occurred during the saving process.`);
    console.error(e);
  }
}

Running examples

There are a handful of examples provided. To run any one of them make sure to clone the 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:

  • bool - an 8-bit value representing either true or false.
    • Encoded as 1 if true, and as 0 if false.
  • byte - an 8-bit value representing an unsigned number between 0 and 255.
    • Encoded as-is
  • i32 - a 32-bit signed integer number container.
    • Encoded as-is
  • f32 - a 32-bit signed floating-point number container.
    • Encoded as-is
  • string - a variable-length string of ASCII characters.
    • A string of characters followed by a '\0' terminal character.
import { BufferWriter, BufferReader, byte, string } from 'typed-binary';

const buffer = Buffer.alloc(16);
const writer = new BufferWriter(buffer);
const reader = new BufferReader(buffer);

// Writing four bytes into the buffer
byte.write(writer, 'W'.charCodeAt(0));
byte.write(writer, 'o'.charCodeAt(0));
byte.write(writer, 'w'.charCodeAt(0));
byte.write(writer, 0);

console.log(string.read(reader)); // Wow

Objects

Objects store their properties in key-ascending-alphabetical order, one next to another.

Simple objects

import { BufferWriter, BufferReader, i32, string, object } from 'typed-binary';

const buffer = Buffer.alloc(16);
const writer = new BufferWriter(buffer);
const reader = new BufferReader(buffer);

// Simple object schema
const Person = object({
  firstName: string,
  lastName: string,
  age: i32,
});

// Writing a Person
Person.write(writer, {
  firstName: 'John',
  lastName: 'Doe',
  age: 43,
});

console.log(JSON.stringify(Person.read(reader).address)); // { "firstName": "John", ... }

Generic objects

This feature allows for the parsing of a type that contains different fields depending on it's previous values. For example, if you want to store an animal description, certain animal types might have differing features from one another.

Keyed by strings:

import {
  BufferWriter,
  BufferReader,
  i32,
  string,
  bool,
  generic,
  object,
} from 'typed-binary';

// Generic object schema
const Animal = generic(
  {
    nickname: string,
    age: i32,
  },
  {
    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}`);
}

Keyed by an enum (byte):

import { BufferWriter, BufferReader, i32, string, genericEnum, object } from 'typed-binary';

enum AnimalType = {
    DOG = 0,
    CAT = 1,
};

// Generic (enum) object schema
const Animal = genericEnum({
    nickname: string,
    age: i32,
}, {
    [AnimalType.DOG]: object({ // Animal can be a dog
        breed: string,
    }),
    [AnimalType.CAT]: object({ // Animal can be a cat
        striped: bool,
    }),
});

// ...
// Same as for the string keyed case
// ...

// -- 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}`);
}

Arrays

The items are encoded right next to each other. No need to store length information, as that's constant (built into the schema).

import { f32, arrayOf } from 'typed-binary';

const Vector2 = arrayOf(f32, 2);
const Vector3 = arrayOf(f32, 3);
const Vector4 = arrayOf(f32, 4);

Dynamic Arrays

First 4 bytes of encoding are the length of the array, then it's items next to one another.

import { i32, dynamicArrayOf } from 'typed-binary';

const IntArray = dynamicArrayOf(i32);

Tuple

Encodes an ordered set of schemas, one next to another.

import { f32, string, tupleOf } from 'typed-binary';

const Vec3f = tupleOf([f32, f32, f32]);
type Vec3f = Parsed<typeof Vec3f>; // [number, number, number]

const RecordEntry = tupleOf([string, Vec3f]);
type RecordEntry = Parsed<typeof RecordEntry>; // [string, [number, number, number]]

Optionals

Optionals are a good way of ensuring that no excessive data is stored as binary.

They are encoded as:

  • 0 given value === undefined.
  • 1 encoded(value) given value !== undefined.
import {
  BufferWriter,
  BufferReader,
  i32,
  string,
  object,
  optional,
} from 'typed-binary';

const buffer = Buffer.alloc(16);
const writer = new BufferWriter(buffer);
const reader = new BufferReader(buffer);

// Simple object schema
const Address = object({
  city: string,
  street: string,
  postalCode: string,
});

// Simple object schema (with optional field)
const Person = object({
  firstName: string,
  lastName: string,
  age: i32,
  address: optional(Address),
});

// Writing a Person (no address)
Person.write(writer, {
  firstName: 'John',
  lastName: 'Doe',
  age: 43,
});

// Writing a Person (with an address)
Person.write(writer, {
  firstName: 'Jesse',
  lastName: 'Doe',
  age: 38,
  address: {
    city: 'New York',
    street: 'Binary St.',
    postalCode: '11-111',
  },
});

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 start using keyed types. The basic pattern is this:

/**
 * 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: i32,
    next: optional(Recursive),
  }),
);

Recursive types alongside generics

import { i32, 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: i32,
      }),
    },
  ),
);

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.

import {
  ISerialInput,
  ISerialOutput,
  Schema,
  IRefResolver,
} from 'typed-binary';

/**
 * A schema storing radians with 2 bytes of precision.
 */
class RadiansSchema extends Schema<number> {
  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;
    // Quantizing the value to 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);
  }

  measure(_: number, measurer: IMeasurer = new Measurer()): IMeasurer {
    // The size of the data serialized by this schema
    // doesn't depend on the actual value. It's always 2 bytes.
    return measurer.add(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:

/**
 * Writes the value (according to the schema's structure) to the output.
 */
write(output: ISerialOutput, value: T): void;
/**
 * Reads a value (according to the schema's structure) from the input.
 */
read(input: ISerialInput): T;
/**
 * Estimates the size of the value (according to the schema's structure)
 */
measure(value: T | MaxValue, measurer: IMeasurer): IMeasurer;

The ISerialInput/Output interfaces have a basic built-in implementation that reads/writes to a buffer:

import { BufferReader, BufferWriter } from 'typed-binary';

// Creating a fixed-length buffer of arbitrary size (64 bytes).
const buffer = Buffer.alloc(64); // Or new ArrayBuffer(64); on browsers.

const reader = new BufferReader(buffer); // Implements ISerialInput
const writer = new BufferWriter(buffer); // Implements ISerialOutput