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

fix(co.json): allow interface types as generic argument #492

Merged
merged 10 commits into from
Oct 9, 2024
6 changes: 6 additions & 0 deletions .changeset/twenty-moons-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"jazz-tools": patch
"cojson": patch
---

Allow interface types as generic argument in co.json
25 changes: 25 additions & 0 deletions packages/cojson/src/jsonValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,28 @@ export type JsonAtom = string | number | boolean | null;
export type JsonValue = JsonAtom | JsonArray | JsonObject | RawCoID;
export type JsonArray = JsonValue[] | readonly JsonValue[];
export type JsonObject = { [key: string]: JsonValue | undefined };

type AtLeastOne<T, U = {[K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];
type ExcludeEmpty<T> = T extends AtLeastOne<T> ? T : never;

export type CoJsonValue<T> = JsonValue | CoJsonObjectWithIndex<T> | CoJsonArray<T>;
export type CoJsonArray<T> = CoJsonValue<T>[] | readonly CoJsonValue<T>[];

/**
* Since we are forcing Typescript to elaborate the indexes from the given type passing
* non-object values to CoJsonObjectWithIndex will return an empty object
* E.g.
* CoJsonObjectWithIndex<() => void> --> {}
* CoJsonObjectWithIndex<RegExp> --> {}
*
* Applying the ExcludeEmpty type here to make sure we don't accept functions or non-serializable values
*/
export type CoJsonObjectWithIndex<T> = ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue1L<T[K]> | undefined }>;

/**
* Manually handling the nested interface types to not get into infinite recursion issues.
*/
export type CoJsonValue1L<T> = ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue2L<T[K]> | undefined }> | JsonValue;
export type CoJsonValue2L<T> = ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue3L<T[K]> | undefined }> | JsonValue;
export type CoJsonValue3L<T> = ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue4L<T[K]> | undefined }> | JsonValue;
export type CoJsonValue4L<T> = ExcludeEmpty<{ [K in keyof T & string]: JsonValue | undefined }> | JsonValue;
12 changes: 7 additions & 5 deletions packages/jazz-tools/src/implementation/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
type CoValueClass,
isCoValueClass,
CoValueFromRaw,
SchemaInit,
ItemsSym,
MembersSym,
} from "../internal.js";
import { CoJsonValue } from "cojson/src/jsonValue.js";

/** @category Schema definition */
export const Encoders = {
Expand All @@ -26,7 +30,7 @@ export type UnCo<T> = T extends co<infer A> ? A : T;

const optional = {
ref: optionalRef,
json<T extends JsonValue>(): co<T | undefined> {
json<T extends CoJsonValue<T>>(): co<T | undefined> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { [SchemaInit]: "json" satisfies Schema } as any;
},
Expand Down Expand Up @@ -80,7 +84,7 @@ export const co = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { [SchemaInit]: "json" satisfies Schema } as any;
},
json<T extends JsonValue>(): co<T> {
json<T extends CoJsonValue<T>>(): co<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { [SchemaInit]: "json" satisfies Schema } as any;
},
Expand Down Expand Up @@ -169,12 +173,10 @@ export type SchemaFor<Field> = NonNullable<Field> extends CoValue
export type Encoder<V> = {
encode: (value: V) => JsonValue;
decode: (value: JsonValue) => V;
};
}
export type OptionalEncoder<V> =
| Encoder<V>
| {
encode: (value: V | undefined) => JsonValue;
decode: (value: JsonValue) => V | undefined;
};

import { SchemaInit, ItemsSym, MembersSym } from "./symbols.js";
205 changes: 205 additions & 0 deletions packages/jazz-tools/src/tests/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { describe, expectTypeOf, it } from "vitest";
import { CoMap, co } from "../index.web.js";
import { co as valueWithCoMarker } from "../internal.js";

describe("co.json TypeScript validation", () => {
it("should accept serializable types", async () => {
type ValidType = { str: string; num: number; bool: boolean };

class ValidPrimitiveMap extends CoMap {
data = co.json<ValidType>();
}

expectTypeOf(ValidPrimitiveMap.create<ValidPrimitiveMap>)
.parameter(0)
.toEqualTypeOf<{
data: valueWithCoMarker<ValidType>;
}>();
});

it("should accept nested serializable types", async () => {
type NestedType = {
outer: {
inner: {
value: string;
};
};
}

class ValidNestedMap extends CoMap {
data = co.json<NestedType>();
}

expectTypeOf(ValidNestedMap.create<ValidNestedMap>)
.parameter(0)
.toEqualTypeOf<{
data: valueWithCoMarker<NestedType>;
}>();
});

it("should accept types with optional attributes", async () => {
type TypeWithOptional = {
value: string;
optional?: string | null;
}

class ValidMap extends CoMap {
data = co.json<TypeWithOptional>();
}

expectTypeOf(ValidMap.create<ValidMap>)
.parameter(0)
.toEqualTypeOf<{
data: valueWithCoMarker<TypeWithOptional>;
}>();
});

it("should accept nested serializable interfaces", async () => {
interface InnerInterface {
value: string;
}

interface NestedInterface {
outer: {
inner: InnerInterface;
};
}

class ValidNestedMap extends CoMap {
data = co.json<NestedInterface>();
}

expectTypeOf(ValidNestedMap.create<ValidNestedMap>)
.parameter(0)
.toEqualTypeOf<{
data: valueWithCoMarker<NestedInterface>;
}>();
});

it("should accept arrays of serializable types", async () => {
interface ArrayInterface {
numbers: number[];
objects: { id: number; name: string }[];
}

class ValidArrayMap extends CoMap {
data = co.json<ArrayInterface>();
}

expectTypeOf(ValidArrayMap.create<ValidArrayMap>)
.parameter(0)
.toEqualTypeOf<{
data: valueWithCoMarker<ArrayInterface>;
}>();
});

it("should flag interfaces with functions as invalid", async () => {
interface InvalidInterface {
func: () => void;
}

class InvalidFunctionMap extends CoMap {
// @ts-expect-error Should not be considered valid
data = co.json<InvalidInterface>();
}

expectTypeOf(InvalidFunctionMap.create<InvalidFunctionMap>)
.parameter(0)
.toEqualTypeOf<{
data: valueWithCoMarker<InvalidInterface>;
}>();
});

it("should flag types with functions as invalid", async () => {
type InvalidType = { func: () => void };

class InvalidFunctionMap extends CoMap {
// @ts-expect-error Should not be considered valid
data = co.json<InvalidType>();
}

expectTypeOf(InvalidFunctionMap.create<InvalidFunctionMap>)
.parameter(0)
.toEqualTypeOf<{
data: valueWithCoMarker<InvalidType>;
}>();
});

it("should flag types with non-serializable constructors as invalid", async () => {
type InvalidType = { date: Date; regexp: RegExp; symbol: symbol };

class InvalidFunctionMap extends CoMap {
// @ts-expect-error Should not be considered valid
data = co.json<InvalidType>();
}

expectTypeOf(InvalidFunctionMap.create<InvalidFunctionMap>)
.parameter(0)
.toEqualTypeOf<{
data: valueWithCoMarker<InvalidType>;
}>();
});

it("should apply the same validation to optional json", async () => {
type ValidType = {
value: string;
};

type InvalidType = {
value: () => string;
};

class MapWithOptionalJSON extends CoMap {
data = co.optional.json<ValidType>();
// @ts-expect-error Should not be considered valid
data2 = co.optional.json<InvalidType>();
}

expectTypeOf(MapWithOptionalJSON.create<MapWithOptionalJSON>)
.parameter(0)
.toEqualTypeOf<{
data?: valueWithCoMarker<ValidType> | null;
data2?: valueWithCoMarker<InvalidType> | null;
}>();
});

it("should not accept functions", async () => {
class InvalidFunctionMap extends CoMap {
// @ts-expect-error Should not be considered valid
data = co.json<() => void>();
}

expectTypeOf(InvalidFunctionMap.create<InvalidFunctionMap>)
.parameter(0)
.toEqualTypeOf<{
data: valueWithCoMarker<() => void>;
}>();
});

it("should not accept RegExp", async () => {
class InvalidFunctionMap extends CoMap {
// @ts-expect-error Should not be considered valid
data = co.json<RegExp>();
}

expectTypeOf(InvalidFunctionMap.create<InvalidFunctionMap>)
.parameter(0)
.toEqualTypeOf<{
data: valueWithCoMarker<RegExp>;
}>();
});

it("should accept strings and numbers", async () => {
class InvalidFunctionMap extends CoMap {
str = co.json<string>();
num = co.json<number>();
}

expectTypeOf(InvalidFunctionMap.create<InvalidFunctionMap>)
.parameter(0)
.toEqualTypeOf<{
str: valueWithCoMarker<string>;
num: valueWithCoMarker<number>;
}>();
});
});
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading