Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

move oneOf types to src/types, add docs #8

Merged
merged 1 commit into from
Sep 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/.vuepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default defineConfig({
"data_types/size",
"data_types/string",
"data_types/struct",
"data_types/one-of",
"data_types/array",
"data_types/map",
"data_types/optional",
Expand Down
111 changes: 111 additions & 0 deletions doc/data_types/one-of.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# OneOf

OneOf allows you to serialize and deserialize a value of a union type, that is,
a value that can be of one of the multiple types. `sd.oneOf` takes
a definition object whose keys are names of possible types of a value,
and values are `sd.Serdes<T>` which are used to encode and decode
values of that type.

`sd.oneOf` maps each type to unique integer and writes it at the
beginning of the payload to differentiate between types. It's first
parameter is `headSd`, which is `sd.Serdes<number>` used to encode the
unique integer

The following `sd.oneOf` call:

```ts
const oneOf = sd.oneOf(sd.uint8, {
a: sd.uint8,
b: sd.array(sd.uint8, sd.uint8),

foo: sd.struct({
x: sd.float64,
y: sd.float64
})
});
```

will create `sd.Serdes<U>`, where `U` is a union type:

```ts
type U = {
type: "a";
value: number;
} | {
type: "b";
value: number[];
} | {
type: "foo";
value: {
x: number;
y: number;
};
};
```

And types "a", "b" and "foo" will be mapped to unique integer encoded
by `sd.uint8`

## Usage

```ts
const oneOf = sd.oneOf(sd.uint8, {
a: sd.uint8,
b: sd.array(sd.uint8, sd.uint8),

foo: sd.struct({
x: sd.float64,
y: sd.float64
})
});

const { toBytes, fromBytes } = sd.use(oneOf);

const obj: sd.GetType<typeof oneOf> = {
type: "foo",
value: {
x: 42,
y: 42
}
};

const bytes = toBytes(obj);
const result = fromBytes(bytes);

switch (result.type) {
case "a":
console.log(typeof result.value); //number
break;
case "b":
console.log(result.value.join(", "));
break;
case "foo":
console.log(`x = ${result.value.x}, y = ${result.value.y}`);
break;
}
```

## Specifications

`sd.oneOf` first serializes integer which is used to differentiate
different types, and then serializes a value of that type using the
corresponding `sd.Serdes<T>` in the definition object

Values of the integer are mapped to the properties of the definition
object (that is, possible types of a value) starting from `0`, in order in which
the properties appear on the object. So, in the example above values are mapped
to the properties (types) like this:

```
0 -> "a"
1 -> "b"
2 -> "foo"
```

The payload will be in the one of the following forms:

```
[headSd 0][a value (uint8)]
[headSd 1][b number of items (uint8)][...b items (uint8)]
[headSd 2][c.x (float64)][c.y (float64)]
```
41 changes: 15 additions & 26 deletions src/serdes/one-of.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,21 @@
import { define } from "../define";
import { Serdes, StructDefinition } from "../types";

type StringOnly<T> = T extends string ? T : never;

type OneOfMap<T> = {
[K in StringOnly<keyof T>]: {
type: K;
value: T[K];
};
};

type ValueOf<T> = T[keyof T];
type OneOf<T> = ValueOf<OneOfMap<T>>;
import { OneOfFactory } from "../types";

export class InvalidOneOfType extends Error {
constructor(readonly type: number) {
super(`Invalid oneOf type (${type})`);
}
}

export function oneOf<T extends Record<string, unknown>>(
headSd: Serdes<number>,
typeToSerdes: StructDefinition<T>
) {
const types = Object.keys(typeToSerdes) as StringOnly<keyof T>[];

types.sort();
export const oneOf: OneOfFactory = (headSd, typeToSerdes) => {
const types = Object.keys(
typeToSerdes
) as (keyof typeof typeToSerdes)[];

const typeToInt = mapKeysToIndexes(types);
const intToType = swapKeysAndValues(typeToInt);

return define<OneOf<T>>(
return define(
(ctx, data) => {
const i = typeToInt[data.type];
headSd.ser(ctx, i);
Expand All @@ -53,9 +38,13 @@ export function oneOf<T extends Record<string, unknown>>(
return { type, value };
}
);
}
};

type Key = string | number | symbol;

function mapKeysToIndexes<T extends string>(keys: T[]): Record<T, number> {
function mapKeysToIndexes<T extends Key>(
keys: T[]
): Record<T, number> {
const result: Record<T, number> = {} as Record<T, number>;

for (let i = 0; i < keys.length; ++i) {
Expand All @@ -66,9 +55,9 @@ function mapKeysToIndexes<T extends string>(keys: T[]): Record<T, number> {
return result;
}

type Key = string | number | symbol;

function swapKeysAndValues<K extends Key, V extends Key>(obj: Record<K, V>): Record<V, K> {
function swapKeysAndValues<K extends Key, V extends Key>(
obj: Record<K, V>
): Record<V, K> {
const result: Record<V, K> = {} as Record<V, K>;

for (const k of Object.keys(obj) as K[]) {
Expand Down
17 changes: 17 additions & 0 deletions src/types/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,20 @@ export type ArrayFactory = <T>(
) => Serdes<T[]>;

export type OptionalFactory = <T>(sd: Serdes<T>) => Serdes<T | void>;

export type OneOfMap<T> = {
[K in keyof T]: {
type: K;
value: T[K];
};
};

export type ValueOf<T> = T[keyof T];
export type OneOf<T> = ValueOf<OneOfMap<T>>;

export type OneOfFactory = <
T extends Record<string | number | symbol, unknown>
>(
headSd: Serdes<number>,
definition: StructDefinition<T>
) => Serdes<OneOf<T>>;
131 changes: 52 additions & 79 deletions test/serdes/one-of.test.ts
Original file line number Diff line number Diff line change
@@ -1,91 +1,64 @@
import {
float64,
import {
float64,
GetType,
string,
struct,
uint8,
oneOf,
use,
utf8js,
InvalidOneOfType,
boolean
string,
struct,
uint8,
oneOf,
use,
utf8js,
InvalidOneOfType
} from "../../src";

(() => {
const Vector = struct({
x: float64,
y: float64
});
const Vector = struct({
x: float64,
y: float64
});

const Nickname = string(utf8js, uint8);
const Nickname = string(utf8js, uint8);

const Message = use(oneOf(uint8, {
const Message = use(
oneOf(uint8, {
nickname: Nickname,
velocity: Vector
}));

const messages: GetType<typeof Message>[] = [
{
type: "nickname",
value: "Foo"
},

{
type: "velocity",
value: { x: 42, y: 42 }
}
];

test.each(messages)("Works", m => {
const bytes = Message.toBytes(m);
const decoded = Message.fromBytes(bytes);

expect(decoded).toStrictEqual(m);
});

test(`Throws \`${InvalidOneOfType.name}\` when oneOf type is invalid`, () => {
const m: GetType<typeof Message> = {
type: "nickname",
value: "A"
};

const bytes = Message.toBytes(m);

//Replace the type header with something invalid
bytes[0] = 42;

try {
Message.fromBytes(bytes);
fail();
}
catch (e) {
expect(e).toBeInstanceOf(InvalidOneOfType);
expect((e as InvalidOneOfType).type).toBe(42);
}
});
})();

test("oneOf type does not depend on order of properties of the parameter object", () => {
const serdes1 = use(oneOf(uint8, {
a: float64,
b: boolean,
c: string(utf8js, uint8)
}));

//Shuffle properties
const serdes2 = use(oneOf(uint8, {
b: boolean,
c: string(utf8js, uint8),
a: float64
}));
})
);

const messages: GetType<typeof Message>[] = [
{
type: "nickname",
value: "Foo"
},

{
type: "velocity",
value: { x: 42, y: 42 }
}
];

test.each(messages)("Works", (m) => {
const bytes = Message.toBytes(m);
const decoded = Message.fromBytes(bytes);

expect(decoded).toStrictEqual(m);
});

const m: GetType<typeof serdes1> = {
type: "a",
value: Math.PI
test(`Throws \`${InvalidOneOfType.name}\` when oneOf type is invalid`, () => {
const m: GetType<typeof Message> = {
type: "nickname",
value: "A"
};

const bytes1 = serdes1.toBytes(m);
const bytes2 = serdes2.toBytes(m);
const bytes = Message.toBytes(m);

//Replace the type header with something invalid
bytes[0] = 42;

expect(bytes1).toStrictEqual(bytes2);
try {
Message.fromBytes(bytes);
fail();
} catch (e) {
expect(e).toBeInstanceOf(InvalidOneOfType);
expect((e as InvalidOneOfType).type).toBe(42);
}
});