diff --git a/packages/common/core-interfaces/package.json b/packages/common/core-interfaces/package.json index d669229690e3..ea333bc576b5 100644 --- a/packages/common/core-interfaces/package.json +++ b/packages/common/core-interfaces/package.json @@ -36,11 +36,11 @@ "./internal": { "import": { "types": "./lib/internal.d.ts", - "default": "./lib/index.js" + "default": "./lib/internal.js" }, "require": { "types": "./dist/internal.d.ts", - "default": "./dist/index.js" + "default": "./dist/internal.js" } }, "./internal/exposedUtilityTypes": { diff --git a/packages/common/core-interfaces/src/cjs/package.json b/packages/common/core-interfaces/src/cjs/package.json index 5d71c4e59727..e9602be5e0f9 100644 --- a/packages/common/core-interfaces/src/cjs/package.json +++ b/packages/common/core-interfaces/src/cjs/package.json @@ -12,7 +12,7 @@ }, "./internal": { "types": "./internal.d.ts", - "default": "./index.js" + "default": "./internal.js" }, "./internal/exposedUtilityTypes": "./exposedUtilityTypes.js" } diff --git a/packages/common/core-interfaces/src/exposedInternalUtilityTypes.ts b/packages/common/core-interfaces/src/exposedInternalUtilityTypes.ts index 141b872d914b..36db307c19d0 100644 --- a/packages/common/core-interfaces/src/exposedInternalUtilityTypes.ts +++ b/packages/common/core-interfaces/src/exposedInternalUtilityTypes.ts @@ -871,6 +871,9 @@ export namespace InternalUtilityTypes { export type JsonSerializableImpl< T, Options extends Partial & { + /** + * See {@link JsonSerializableOptions} for meaning and expected use. + */ IgnoreInaccessibleMembers?: "ignore-inaccessible-members"; }, TAncestorTypes extends unknown[] = [], @@ -903,7 +906,7 @@ export namespace InternalUtilityTypes { Controls extends FilterControlsWithSubstitution ? /* test for 'any' */ boolean extends (T extends never ? true : false) ? /* 'any' => */ Controls["DegenerateSubstitute"] - : Options["IgnoreInaccessibleMembers"] extends "ignore-inaccessible-members" + : Options extends { IgnoreInaccessibleMembers: "ignore-inaccessible-members" } ? JsonSerializableFilter : /* test for non-public properties (class instance type) */ IfNonPublicProperties< diff --git a/packages/common/core-interfaces/src/internal.ts b/packages/common/core-interfaces/src/internal.ts index aab03dff3ead..9cb411203ea8 100644 --- a/packages/common/core-interfaces/src/internal.ts +++ b/packages/common/core-interfaces/src/internal.ts @@ -7,9 +7,8 @@ // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-restricted-imports export * from "./index.js"; -// Important: all other exports must be type only exports. In package.json exports, -// index.js is listed as the runtime file. This is done so that all imports are -// using the same outer runtime file. (Could be changed if needed.) +export type { JsonString, JsonStringifyOptions } from "./jsonString.js"; +export { JsonStringify, JsonParse } from "./jsonString.js"; export type { JsonTypeToOpaqueJson, OpaqueJsonToJsonType } from "./jsonUtils.js"; diff --git a/packages/common/core-interfaces/src/jsonSerializable.ts b/packages/common/core-interfaces/src/jsonSerializable.ts index 503e9c8d6d15..a16882c4e783 100644 --- a/packages/common/core-interfaces/src/jsonSerializable.ts +++ b/packages/common/core-interfaces/src/jsonSerializable.ts @@ -33,9 +33,17 @@ export interface JsonSerializableOptions { /** * When set, inaccessible (protected and private) members throughout type T are - * ignored as if not present. + * ignored as if not present. Otherwise, inaccessible members are considered + * an error (type checking will mention `SerializationErrorPerNonPublicProperties`). * - * The default value is not present. + * @remarks + * For this option to be set and accurately filter inaccessible members, all + * inaccessible members (if any) must be either inherited, symbol keyed, or + * non-enumerable. + * + * The default is that `IgnoreInaccessibleMembers` property is not specified, + * which means that inaccessible members are considered an error, even if + * they would not be serialized at runtime. */ IgnoreInaccessibleMembers?: "ignore-inaccessible-members"; } diff --git a/packages/common/core-interfaces/src/jsonString.ts b/packages/common/core-interfaces/src/jsonString.ts new file mode 100644 index 000000000000..2fdd092589f0 --- /dev/null +++ b/packages/common/core-interfaces/src/jsonString.ts @@ -0,0 +1,126 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- incorrect rule: misunderstands `declare`d types. +import type { BrandedType } from "./brandedType.js"; +import type { InternalUtilityTypes } from "./exposedInternalUtilityTypes.js"; +import type { JsonDeserialized } from "./jsonDeserialized.js"; +import type { JsonSerializable, JsonSerializableOptions } from "./jsonSerializable.js"; + +/** + * Brand for JSON that has been stringified. + * + * Usage: Intersect with another type to apply branding. + * + * @sealed + */ +declare class JsonStringBrand extends BrandedType> { + public toString(): string; + protected readonly EncodedValue: T; + private constructor(); +} + +/** + * Distributes `JsonStringBrand` over union elements of T. + * + * @remarks + * This is useful to allow `JsonString` to be assigned to `JsonString | JsonString`. + * + * The downside is that enums are expanded to union of members and thus cannot be + * reconstituted exactly (even if IntelliSense shows the original enum type). This + * can be removed if exact enum preservation is found to be more important. + */ +type DistributeJsonStringBrand = T extends unknown ? JsonStringBrand : never; + +/** + * Distributes branding over union elements of T unless result could prevent T + * from being reconstituted (as in the case of an enum type), in which case it + * falls back to a single JsonStringBrand for the entire T. + * + * @remarks + * Note that an enum unioned with anything else will be distributed. It seems + * however that TypeScript can/will reconstitute the enum type in that case. + */ +type BrandForJsonString = InternalUtilityTypes.IfSameType< + DistributeJsonStringBrand extends JsonStringBrand ? U : never, + T, + DistributeJsonStringBrand, + JsonStringBrand +>; + +/** + * Branded `string` for JSON that has been stringified. + * + * @remarks + * + * Use {@link JsonStringify} to encode JSON producing values of this type and + * {@link JsonParse} to decode them. + * + * For custom encoding/decoding: + * + * - cast to with `as unknown as JsonString` when value of type `T` has been stringified. + * + * - use a form of {@link JsonDeserialized} for safety when parsing. + * + * @sealed + * @internal + */ +export type JsonString = string & BrandForJsonString; + +/** + * Compile options for {@link JsonStringify}. + * + * @remarks + * This only impacts type checking -- it has no impact on runtime. + * + * The options are currently a subset of {@link JsonSerializableOptions}, specifically + * only `IgnoreInaccessibleMembers` is supported. + * + * No instance of this should ever exist at runtime. + * + * @privateRemarks + * Consider adding `AllowUnknown` option to allow precisely `unknown` types to + * be passed through. With `unknown` expected successful serialization could not + * be checked at compile time. At deserialization time, `unknown` does not + * guarantee any type and thus allowing does not erode type safety. + * + * @internal + */ +export type JsonStringifyOptions = Pick; + +/** + * Performs basic JSON serialization using `JSON.stringify` and brands the result as {@link JsonString}``. + * + * @remarks + * Parameter `value` must be JSON-serializable and thus type T is put through filter {@link JsonSerializable}. + * + * @internal + */ +export const JsonStringify = JSON.stringify as < + T, + Options extends JsonStringifyOptions = Record, +>( + value: JsonSerializable< + T, + // Make sure only options that are known are passed through. + Pick> + >, +) => JsonString; + +/** + * Performs basic JSON parsing using `JSON.parse` given a {@link JsonString}`` (`string`). + * + * @remarks + * Return type is filtered through {@link JsonDeserialized}`` for best accuracy. + * + * Note that `JsonParse` cannot verify at runtime that the input is valid JSON + * or that it matches type T. It is the caller's responsibility to ensure that + * the input is valid JSON and the output conforms to the expected type. + * + * @internal + */ +export const JsonParse = JSON.parse as >( + text: T, +) => T extends JsonString ? JsonDeserialized : unknown; diff --git a/packages/common/core-interfaces/src/test/jsonDeserialized.spec.ts b/packages/common/core-interfaces/src/test/jsonDeserialized.spec.ts index 242f1fd291d6..bb3ddf56d476 100644 --- a/packages/common/core-interfaces/src/test/jsonDeserialized.spec.ts +++ b/packages/common/core-interfaces/src/test/jsonDeserialized.spec.ts @@ -21,6 +21,8 @@ import type { BrandedKey, BrandedString, DecodedValueDirectoryOrRequiredState, + DeserializedOpaqueSerializableInRecursiveStructure, + DeserializedOpaqueSerializableAndDeserializedInRecursiveStructure, DirectoryOfValues, ObjectWithOptionalRecursion, } from "./testValues.js"; @@ -218,6 +220,12 @@ import { opaqueSerializableObjectExpectingBigintSupport, opaqueDeserializedObjectExpectingBigintSupport, opaqueSerializableAndDeserializedObjectExpectingBigintSupport, + jsonStringOfString, + jsonStringOfObjectWithArrayOfNumbers, + jsonStringOfStringRecordOfNumbers, + jsonStringOfStringRecordOfNumberOrUndefined, + jsonStringOfBigInt, + jsonStringOfUnknown, } from "./testValues.js"; import type { IFluidHandle } from "@fluidframework/core-interfaces"; @@ -227,7 +235,6 @@ import type { JsonTypeWith, NonNullJsonObjectWith, OpaqueJsonDeserialized, - OpaqueJsonSerializable, } from "@fluidframework/core-interfaces/internal/exposedUtilityTypes"; /** @@ -375,6 +382,36 @@ describe("JsonDeserialized", () => { assertIdenticalTypes(resultRead, brandedString); assertNever>(); }); + it("`JsonString`", () => { + const resultRead = passThru(jsonStringOfString); + assertIdenticalTypes(resultRead, jsonStringOfString); + assertNever>(); + }); + it("`JsonString<{ arrayOfNumbers: number[] }>`", () => { + const resultRead = passThru(jsonStringOfObjectWithArrayOfNumbers); + assertIdenticalTypes(resultRead, jsonStringOfObjectWithArrayOfNumbers); + assertNever>(); + }); + it("`JsonString>`", () => { + const resultRead = passThru(jsonStringOfStringRecordOfNumbers); + assertIdenticalTypes(resultRead, jsonStringOfStringRecordOfNumbers); + assertNever>(); + }); + it("`JsonString>`", () => { + const resultRead = passThru(jsonStringOfStringRecordOfNumberOrUndefined); + assertIdenticalTypes(resultRead, jsonStringOfStringRecordOfNumberOrUndefined); + assertNever>(); + }); + it("JsonString", () => { + const resultRead = passThru(jsonStringOfBigInt); + assertIdenticalTypes(resultRead, jsonStringOfBigInt); + assertNever>(); + }); + it("JsonString", () => { + const resultRead = passThru(jsonStringOfUnknown); + assertIdenticalTypes(resultRead, jsonStringOfUnknown); + assertNever>(); + }); }); describe("unions with unsupported primitive types preserve supported types", () => { @@ -523,11 +560,15 @@ describe("JsonDeserialized", () => { }); it("array of functions with properties becomes ({...}|null)[]", () => { const resultRead = passThru(arrayOfFunctionsWithProperties, [null]); + // Note: a function with properties always results in `null`, but a property bag + // with a function is the property bag. Allow either to avoid order-based logic. assertIdenticalTypes(resultRead, createInstanceOf<({ property: number } | null)[]>()); assertNever>(); }); it("array of objects and functions becomes ({...}|null)[]", () => { const resultRead = passThru(arrayOfObjectAndFunctions, [{ property: 6 }]); + // Note: a function with properties always results in `null`, but a property bag + // with a function is the property bag. Allow either to avoid order-based logic. assertIdenticalTypes(resultRead, createInstanceOf<({ property: number } | null)[]>()); assertNever>(); }); @@ -1726,15 +1767,6 @@ describe("JsonDeserialized", () => { }); it("object with OpaqueJsonSerializable in recursion is unrolled one time with OpaqueJsonDeserialized", () => { const resultRead = passThru(opaqueSerializableInRecursiveStructure); - interface DeserializedOpaqueSerializableInRecursiveStructure { - items: { - [x: string | number]: - | OpaqueJsonDeserialized>> - | { - value?: OpaqueJsonDeserialized; - }; - }; - } assertIdenticalTypes( resultRead, createInstanceOf(), @@ -1757,22 +1789,9 @@ describe("JsonDeserialized", () => { // It might be better to preserve the intersection and return original type. it("object with OpaqueJsonSerializable & OpaqueJsonDeserialized in recursion is unrolled one time with OpaqueJsonDeserialized", () => { const resultRead = passThru(opaqueSerializableAndDeserializedInRecursiveStructure); - interface DeserializedOpaqueSerializableInRecursiveStructure { - items: { - [x: string | number]: - | OpaqueJsonDeserialized< - DirectoryOfValues< - OpaqueJsonSerializable & OpaqueJsonDeserialized - > - > - | { - value?: OpaqueJsonDeserialized; - }; - }; - } assertIdenticalTypes( resultRead, - createInstanceOf(), + createInstanceOf(), ); assertNever>(); const transparentResult = revealOpaqueJson(resultRead); @@ -1781,7 +1800,7 @@ describe("JsonDeserialized", () => { createInstanceOf<{ items: { [x: string | number]: - | DeserializedOpaqueSerializableInRecursiveStructure + | DeserializedOpaqueSerializableAndDeserializedInRecursiveStructure | { value?: JsonTypeWith; }; diff --git a/packages/common/core-interfaces/src/test/jsonSerializable.spec.ts b/packages/common/core-interfaces/src/test/jsonSerializable.spec.ts index ef42e451c539..f407fe1817c1 100644 --- a/packages/common/core-interfaces/src/test/jsonSerializable.spec.ts +++ b/packages/common/core-interfaces/src/test/jsonSerializable.spec.ts @@ -205,6 +205,12 @@ import { opaqueSerializableObjectExpectingBigintSupport, opaqueDeserializedObjectExpectingBigintSupport, opaqueSerializableAndDeserializedObjectExpectingBigintSupport, + jsonStringOfString, + jsonStringOfObjectWithArrayOfNumbers, + jsonStringOfStringRecordOfNumbers, + jsonStringOfStringRecordOfNumberOrUndefined, + jsonStringOfBigInt, + jsonStringOfUnknown, } from "./testValues.js"; import type { IFluidHandle } from "@fluidframework/core-interfaces"; @@ -240,8 +246,7 @@ import type { export function passThru< const T, TExpected, - // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types - Options extends JsonSerializableOptions = {}, + Options extends JsonSerializableOptions = Record, >( filteredIn: JsonSerializable, expectedDeserialization?: JsonDeserialized, @@ -444,6 +449,36 @@ describe("JsonSerializable", () => { assertIdenticalTypes(filteredIn, brandedString); assertIdenticalTypes(filteredIn, out); }); + it("`JsonString`", () => { + const { filteredIn, out } = passThru(jsonStringOfString); + assertIdenticalTypes(filteredIn, jsonStringOfString); + assertIdenticalTypes(filteredIn, out); + }); + it("`JsonString<{ arrayOfNumbers: number[] }>`", () => { + const { filteredIn, out } = passThru(jsonStringOfObjectWithArrayOfNumbers); + assertIdenticalTypes(filteredIn, jsonStringOfObjectWithArrayOfNumbers); + assertIdenticalTypes(filteredIn, out); + }); + it("`JsonString>`", () => { + const { filteredIn, out } = passThru(jsonStringOfStringRecordOfNumbers); + assertIdenticalTypes(filteredIn, jsonStringOfStringRecordOfNumbers); + assertIdenticalTypes(filteredIn, out); + }); + it("`JsonString>`", () => { + const { filteredIn, out } = passThru(jsonStringOfStringRecordOfNumberOrUndefined); + assertIdenticalTypes(filteredIn, jsonStringOfStringRecordOfNumberOrUndefined); + assertIdenticalTypes(filteredIn, out); + }); + it("JsonString", () => { + const { filteredIn, out } = passThru(jsonStringOfBigInt); + assertIdenticalTypes(filteredIn, jsonStringOfBigInt); + assertIdenticalTypes(filteredIn, out); + }); + it("JsonString", () => { + const { filteredIn, out } = passThru(jsonStringOfUnknown); + assertIdenticalTypes(filteredIn, jsonStringOfUnknown); + assertIdenticalTypes(filteredIn, out); + }); }); describe("supported literal types", () => { @@ -1593,8 +1628,10 @@ describe("JsonSerializable", () => { }); it("`string` indexed record of `unknown`", () => { - // @ts-expect-error not assignable to parameter of type '{ [x: string]: JsonTypeWith | OpaqueJsonSerializable; }'. - const { filteredIn } = passThru(stringRecordOfUnknown); + const { filteredIn } = passThru( + // @ts-expect-error not assignable to parameter of type '{ [x: string]: JsonTypeWith | OpaqueJsonSerializable; }'. + stringRecordOfUnknown, + ); assertIdenticalTypes( filteredIn, createInstanceOf<{ @@ -1603,8 +1640,10 @@ describe("JsonSerializable", () => { ); }); it("`Partial<>` `string` indexed record of `unknown`", () => { - // @ts-expect-error not assignable to parameter of type '{ [x: string]: JsonTypeWith | OpaqueJsonSerializable; }'. - const { filteredIn } = passThru(partialStringRecordOfUnknown); + const { filteredIn } = passThru( + // @ts-expect-error not assignable to parameter of type '{ [x: string]: JsonTypeWith | OpaqueJsonSerializable; }'. + partialStringRecordOfUnknown, + ); assertIdenticalTypes( filteredIn, createInstanceOf<{ @@ -1620,8 +1659,11 @@ describe("JsonSerializable", () => { // Allowing `undefined` is possible if all indexed properties are // identifiable. But rather than that, an implementation of `Partial<>` // that doesn't add `| undefined` for index signatures would be preferred. - // @ts-expect-error not assignable to type '{ "error required property may not allow `undefined` value": never; }' - const { filteredIn } = passThru(partialStringRecordOfNumbers, { key1: 0 }); + const { filteredIn } = passThru( + // @ts-expect-error not assignable to type '{ "error required property may not allow `undefined` value": never; }' + partialStringRecordOfNumbers, + { key1: 0 }, + ); assertIdenticalTypes( filteredIn, createInstanceOf<{ @@ -1638,8 +1680,11 @@ describe("JsonSerializable", () => { // Allowing `undefined` is possible if all indexed properties are // identifiable. But rather than that, an implementation of `Partial<>` // that doesn't add `| undefined` for index signatures would be preferred. - // @ts-expect-error not assignable to type '{ "error required property may not allow `undefined` value": never; }' - const { filteredIn } = passThru(partialTemplatedRecordOfNumbers, { key1: 0 }); + const { filteredIn } = passThru( + // @ts-expect-error not assignable to type '{ "error required property may not allow `undefined` value": never; }' + partialTemplatedRecordOfNumbers, + { key1: 0 }, + ); assertIdenticalTypes( filteredIn, createInstanceOf<{ diff --git a/packages/common/core-interfaces/src/test/jsonString.spec.ts b/packages/common/core-interfaces/src/test/jsonString.spec.ts new file mode 100644 index 000000000000..dc9a12ea1ecd --- /dev/null +++ b/packages/common/core-interfaces/src/test/jsonString.spec.ts @@ -0,0 +1,158 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { assertIdenticalTypes, createInstanceOf, parameterAcceptedAs } from "./testUtils.js"; +import type { ConstHeterogenousEnum, NumericEnum } from "./testValues.js"; +import { + jsonStringOfString, + jsonStringOfObjectWithArrayOfNumbers, + jsonStringOfStringRecordOfNumbers, + jsonStringOfStringRecordOfNumberOrUndefined, + jsonStringOfBigInt, + jsonStringOfUnknown, +} from "./testValues.js"; + +import type { JsonString } from "@fluidframework/core-interfaces/internal"; +import { JsonStringify } from "@fluidframework/core-interfaces/internal"; + +const jsonStringOfLiteral = JsonStringify("literal"); + +describe("JsonString", () => { + it("`JsonString` is assignable to `string`", () => { + parameterAcceptedAs(jsonStringOfString); + parameterAcceptedAs(jsonStringOfLiteral); + parameterAcceptedAs(jsonStringOfObjectWithArrayOfNumbers); + parameterAcceptedAs(jsonStringOfStringRecordOfNumbers); + parameterAcceptedAs(jsonStringOfStringRecordOfNumberOrUndefined); + parameterAcceptedAs(jsonStringOfBigInt); + parameterAcceptedAs(jsonStringOfUnknown); + }); + + it("`string` is not assignable to `JsonString`", () => { + parameterAcceptedAs>( + // @ts-expect-error Type 'string' is not assignable to type 'JsonString' + "a string", + ); + }); + + it("object is not assignable to `JsonString`", () => { + parameterAcceptedAs>( + // @ts-expect-error Type '{ property: string; }' is not assignable to type 'JsonString' + { property: "value" }, + ); + }); + + describe("is covariant over T", () => { + it('`JsonString<"literal">` is assignable to `JsonString`', () => { + parameterAcceptedAs>(jsonStringOfLiteral); + }); + + it("`JsonString>` is assignable to `JsonString>`", () => { + parameterAcceptedAs>>( + jsonStringOfStringRecordOfNumbers, + ); + }); + + it("`JsonString>` is assignable to `JsonString>`", () => { + parameterAcceptedAs>>( + jsonStringOfStringRecordOfNumberOrUndefined, + ); + }); + + it("`JsonString` is assignable to `JsonString`", () => { + parameterAcceptedAs>(jsonStringOfBigInt); + }); + + it("`JsonString | JsonString` is assignable to `JsonString`", () => { + const jsonStringUnion = + Math.random() < 0.5 ? jsonStringOfString : jsonStringOfObjectWithArrayOfNumbers; + + parameterAcceptedAs>(jsonStringUnion); + }); + }); + + describe("is not contravariant over T", () => { + it('`JsonString` is not assignable to `JsonString<"literal">`', () => { + parameterAcceptedAs>( + // @ts-expect-error Type 'string' is not assignable to type '"literal"' + jsonStringOfString, + ); + }); + + it("`JsonString>` is not assignable to `JsonString>`", () => { + parameterAcceptedAs>>( + // @ts-expect-error Type 'Record' is not assignable to type 'Record' + jsonStringOfStringRecordOfNumberOrUndefined, + ); + }); + + it("`JsonString` is not assignable to `JsonString`", () => { + parameterAcceptedAs>( + // @ts-expect-error Type 'unknown' is not assignable to type 'bigint' + jsonStringOfUnknown, + ); + }); + + it("`JsonString` is not assignable to `JsonString`", () => { + parameterAcceptedAs>( + // @ts-expect-error Type 'JsonString' is not assignable to type 'JsonString' + jsonStringOfString as JsonString, + ); + }); + }); + + type ExtractJsonStringBrand> = T extends string & infer B + ? B + : never; + + // Practically `JsonString` is the same as `JsonString | JsonString` + // since all encodings are the same and parsing from either produces the same + // `A|B` result. + it("`JsonString` is assignable to `JsonString | JsonString` without enums involved", () => { + // Setup + const explicitBrandUnion = createInstanceOf< + string & + ( + | ExtractJsonStringBrand> + | ExtractJsonStringBrand> + ) + >(); + const explicitPostBrandUnion = createInstanceOf< + | (string & ExtractJsonStringBrand>) + | (string & ExtractJsonStringBrand>) + >(); + assertIdenticalTypes(explicitBrandUnion, explicitPostBrandUnion); + assertIdenticalTypes( + explicitBrandUnion, + createInstanceOf>(), + ); + assertIdenticalTypes( + explicitPostBrandUnion, + createInstanceOf | JsonString<{ arrayOfNumbers: number[] }>>(), + ); + assertIdenticalTypes( + createInstanceOf>(), + createInstanceOf | JsonString<{ arrayOfNumbers: number[] }>>(), + ); + // Act and Verify + parameterAcceptedAs | JsonString<{ arrayOfNumbers: number[] }>>( + jsonStringOfString as JsonString, + ); + }); + + it("`JsonString` is assignable to `JsonString | JsonString` with enums involved", () => { + parameterAcceptedAs | JsonString<{ arrayOfNumbers: number[] }>>( + createInstanceOf>(), + ); + + parameterAcceptedAs< + JsonString | JsonString<{ arrayOfNumbers: number[] }> + >(createInstanceOf>()); + + parameterAcceptedAs | JsonString>( + createInstanceOf>(), + ); + }); +}); diff --git a/packages/common/core-interfaces/src/test/jsonStringifyAndParse.spec.ts b/packages/common/core-interfaces/src/test/jsonStringifyAndParse.spec.ts new file mode 100644 index 000000000000..4c2723998cba --- /dev/null +++ b/packages/common/core-interfaces/src/test/jsonStringifyAndParse.spec.ts @@ -0,0 +1,2803 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/* eslint-disable unicorn/no-null */ + +import { strict as assert } from "node:assert"; + +import { assertIdenticalTypes, createInstanceOf } from "./testUtils.js"; +import type { + BrandedString, + DeserializedOpaqueSerializableInRecursiveStructure, + DeserializedOpaqueSerializableAndDeserializedInRecursiveStructure, + ObjectWithOptionalRecursion, + ObjectWithSymbolOrRecursion, +} from "./testValues.js"; +// Note: some values are commented out as not interesting to add coverage for (but acknowledge they exist to test). +// This import list should be kept mostly in-sync with jsonDeserialized.spec.ts. +import { + boolean, + number, + string, + numericEnumValue, + NumericEnum, + stringEnumValue, + StringEnum, + constHeterogenousEnumValue, + ConstHeterogenousEnum, + computedEnumValue, + ComputedEnum, + objectWithLiterals, + arrayOfLiterals, + tupleWithLiterals, + symbol, + uniqueSymbol, + bigint, + aFunction, + // unknownValueOfSimpleRecord, + // unknownValueWithBigint, + voidValue, + stringOrSymbol, + bigintOrString, + bigintOrSymbol, + numberOrBigintOrSymbol, + functionWithProperties, + objectAndFunction, + arrayOfNumbers, + arrayOfNumbersSparse, + arrayOfNumbersOrUndefined, + arrayOfBigints, + arrayOfSymbols, + arrayOfUnknown, + arrayOfFunctions, + arrayOfFunctionsWithProperties, + arrayOfObjectAndFunctions, + arrayOfBigintOrObjects, + arrayOfSymbolOrObjects, + arrayOfBigintOrSymbols, + arrayOfNumberBigintOrSymbols, + readonlyArrayOfNumbers, + readonlyArrayOfObjects, + object, + emptyObject, + objectWithBoolean, + objectWithNumber, + objectWithString, + objectWithSymbol, + objectWithBigint, + objectWithFunction, + objectWithFunctionWithProperties, + objectWithObjectAndFunction, + objectWithBigintOrString, + objectWithBigintOrSymbol, + objectWithNumberOrBigintOrSymbol, + objectWithFunctionOrSymbol, + objectWithStringOrSymbol, + objectWithUndefined, + objectWithUnknown, + objectWithOptionalUnknown, + // Skipped as type checking varies with exactOptionalPropertyTypes setting. See + // jsonDeserialized.spec.ts, jsonSerializable.exactOptionalPropertyTypes.true.spec.ts, + // and jsonSerializable.exactOptionalPropertyTypes.false.spec.ts. + // objectWithOptionalUndefined, + objectWithOptionalSymbol, + objectWithOptionalBigint, + objectWithNumberKey, + objectWithSymbolKey, + objectWithUniqueSymbolKey, + objectWithArrayOfNumbers, + objectWithArrayOfNumbersSparse, + objectWithArrayOfNumbersOrUndefined, + objectWithArrayOfBigints, + objectWithArrayOfSymbols, + objectWithArrayOfUnknown, + objectWithArrayOfFunctions, + objectWithArrayOfFunctionsWithProperties, + objectWithArrayOfObjectAndFunctions, + objectWithArrayOfBigintOrObjects, + objectWithArrayOfSymbolOrObjects, + objectWithReadonlyArrayOfNumbers, + objectWithOptionalNumberNotPresent, + objectWithOptionalNumberUndefined, + objectWithOptionalNumberDefined, + objectWithNumberOrUndefinedUndefined, + objectWithNumberOrUndefinedNumbered, + objectWithOptionalUndefinedEnclosingRequiredUndefined, + objectWithReadonly, + objectWithReadonlyViaGetter, + objectWithGetter, + objectWithGetterViaValue, + objectWithSetter, + objectWithSetterViaValue, + objectWithMatchedGetterAndSetterProperty, + objectWithMatchedGetterAndSetterPropertyViaValue, + objectWithMismatchedGetterAndSetterProperty, + objectWithMismatchedGetterAndSetterPropertyViaValue, + objectWithNever, + stringRecordOfNumbers, + stringRecordOfUndefined, + stringRecordOfNumberOrUndefined, + stringRecordOfSymbolOrBoolean, + stringRecordOfUnknown, + stringOrNumberRecordOfStrings, + stringOrNumberRecordOfObjects, + partialStringRecordOfNumbers, + partialStringRecordOfUnknown, + templatedRecordOfNumbers, + partialTemplatedRecordOfNumbers, + // templatedRecordOfUnknown, + // mixedRecordOfUnknown, + stringRecordOfNumbersOrStringsWithKnownProperties, + // stringRecordOfUnknownWithKnownProperties, + // partialStringRecordOfUnknownWithKnownProperties, + // stringRecordOfUnknownWithOptionalKnownProperties, + // stringRecordOfUnknownWithKnownUnknown, + // stringRecordOfUnknownWithOptionalKnownUnknown, + stringOrNumberRecordOfStringWithKnownNumber, + stringOrNumberRecordOfUndefinedWithKnownNumber, + objectWithPossibleRecursion, + objectWithOptionalRecursion, + objectWithEmbeddedRecursion, + objectWithAlternatingRecursion, + objectWithSelfReference, + objectWithSymbolOrRecursion, + objectWithUnknownAdjacentToOptionalRecursion, + // objectWithOptionalUnknownAdjacentToOptionalRecursion, + objectWithUnknownInOptionalRecursion, + // objectWithOptionalUnknownInOptionalRecursion, + selfRecursiveFunctionWithProperties, + selfRecursiveObjectAndFunction, + objectInheritingOptionalRecursionAndWithNestedSymbol, + simpleJson, + simpleImmutableJson, + jsonObject, + immutableJsonObject, + classInstanceWithPrivateData, + classInstanceWithPrivateMethod, + classInstanceWithPrivateGetter, + classInstanceWithPrivateSetter, + classInstanceWithPublicData, + classInstanceWithPublicMethod, + objectWithClassWithPrivateDataInOptionalRecursion, + functionObjectWithPrivateData, + functionObjectWithPublicData, + classInstanceWithPrivateDataAndIsFunction, + classInstanceWithPublicDataAndIsFunction, + // These `ClassWith*` values are used to verify `instanceof` results of + // parse and not expected to be test cases themselves. + ClassWithPrivateData, + ClassWithPrivateMethod, + ClassWithPrivateGetter, + ClassWithPrivateSetter, + ClassWithPublicData, + ClassWithPublicMethod, + mapOfStringsToNumbers, + readonlyMapOfStringsToNumbers, + setOfNumbers, + readonlySetOfNumbers, + brandedNumber, + brandedString, + brandedObject, + brandedObjectWithString, + objectWithBrandedNumber, + objectWithBrandedString, + brandedStringIndexOfBooleans, + brandedStringAliasIndexOfBooleans, + brandedStringRecordOfBooleans, + brandedStringAliasRecordOfBooleans, + brandedStringIndexOfNumbers, + brandedStringAliasIndexOfNumbers, + brandedStringRecordOfNumbers, + brandedStringAliasRecordOfNumbers, + brandedStringAliasIndexOfTrueOrUndefined, + datastore, + fluidHandleToNumber, + objectWithFluidHandle, + // objectWithFluidHandleOrRecursion, + opaqueSerializableObject, + opaqueDeserializedObject, + opaqueSerializableAndDeserializedObject, + opaqueSerializableUnknown, + opaqueDeserializedUnknown, + opaqueSerializableAndDeserializedUnknown, + objectWithOpaqueSerializableUnknown, + objectWithOpaqueDeserializedUnknown, + opaqueSerializableInRecursiveStructure, + opaqueDeserializedInRecursiveStructure, + opaqueSerializableAndDeserializedInRecursiveStructure, + opaqueSerializableObjectRequiringBigintSupport, + opaqueDeserializedObjectRequiringBigintSupport, + opaqueSerializableAndDeserializedObjectRequiringBigintSupport, + opaqueSerializableObjectExpectingBigintSupport, + opaqueDeserializedObjectExpectingBigintSupport, + opaqueSerializableAndDeserializedObjectExpectingBigintSupport, + jsonStringOfString, + jsonStringOfObjectWithArrayOfNumbers, + jsonStringOfStringRecordOfNumbers, + jsonStringOfStringRecordOfNumberOrUndefined, + jsonStringOfBigInt, + jsonStringOfUnknown, +} from "./testValues.js"; + +import type { + JsonString, + JsonStringifyOptions, +} from "@fluidframework/core-interfaces/internal"; +import { JsonParse, JsonStringify } from "@fluidframework/core-interfaces/internal"; +import type { + JsonDeserialized, + JsonSerializable, + JsonTypeWith, + NonNullJsonObjectWith, + OpaqueJsonDeserialized, +} from "@fluidframework/core-interfaces/internal/exposedUtilityTypes"; + +/** + * Defined combining known API for `JsonStringify` and `JsonParse` - effectively + * it should always be a proxy for their use. + * Internally, value given is sent through `JsonStringify` (captured) and then + * sent through `JsonParse` to ensure it is unchanged or converted to given + * optional expected value. + * + * @param v - value to pass through JSON serialization + * @param expectedDeserialization - alternate value to compare against after round-trip + * @returns record of the serialized and deserialized + * results as `stringified` and `out` respectively. + */ +export function stringifyThenParse< + const T, + TExpected, + Options extends JsonStringifyOptions = Record, +>( + v: JsonSerializable>>, + expectedDeserialization?: JsonDeserialized, +): { + stringified: ReturnType>; + out: ReturnType>>>; + // Replace above with below if `JsonParse` argument is `JsonString` + // out: ReturnType>; +} { + const stringified = JsonStringify(v); + if (stringified === undefined) { + throw new Error("JSON.stringify returned undefined"); + } + if (expectedDeserialization !== undefined) { + // When there is a failure, checking the stringified value can be helpful. + const expectedStringified = JSON.stringify(expectedDeserialization); + assert.equal(stringified, expectedStringified); + } + const result = JsonParse(stringified); + // Don't use nullish coalescing here to allow for `null` to be expected. + const expected = + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + expectedDeserialization === undefined ? v : expectedDeserialization; + assert.deepStrictEqual(result, expected); + return { stringified, out: result }; +} + +/** + * Similar to {@link stringifyThenParse} but ignores hidden (private/protected) members. + */ +function stringifyIgnoringInaccessibleMembersThenParse( + v: JsonSerializable, + expected?: JsonDeserialized, +): ReturnType< + typeof stringifyThenParse< + T, + TExpected, + { IgnoreInaccessibleMembers: "ignore-inaccessible-members" } + > +> { + return stringifyThenParse< + T, + TExpected, + { IgnoreInaccessibleMembers: "ignore-inaccessible-members" } + >(v, expected); +} + +describe("JsonStringify and JsonParse", () => { + describe("expected usage", () => { + describe("supports primitive types", () => { + it("`boolean`", () => { + const { stringified, out } = stringifyThenParse(boolean); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, boolean); + }); + it("`number`", () => { + const { stringified, out } = stringifyThenParse(number); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, number); + }); + it("`string`", () => { + const { stringified, out } = stringifyThenParse(string); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, string); + }); + it("numeric enum", () => { + const { stringified, out } = stringifyThenParse(numericEnumValue); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, numericEnumValue); + }); + it("string enum", () => { + const { stringified, out } = stringifyThenParse(stringEnumValue); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, stringEnumValue); + }); + it("const heterogenous enum", () => { + const { stringified, out } = stringifyThenParse(constHeterogenousEnumValue); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, constHeterogenousEnumValue); + }); + it("computed enum", () => { + const { stringified, out } = stringifyThenParse(computedEnumValue); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, computedEnumValue); + }); + it("branded `number`", () => { + const { stringified, out } = stringifyThenParse(brandedNumber); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, brandedNumber); + }); + it("branded `string`", () => { + const { stringified, out } = stringifyThenParse(brandedString); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, brandedString); + }); + it("`JsonString`", () => { + const { stringified, out } = stringifyThenParse(jsonStringOfString); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, jsonStringOfString); + }); + it("`JsonString<{ arrayOfNumbers: number[] }>`", () => { + const { stringified, out } = stringifyThenParse(jsonStringOfObjectWithArrayOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, jsonStringOfObjectWithArrayOfNumbers); + }); + it("`JsonString>`", () => { + const { stringified, out } = stringifyThenParse(jsonStringOfStringRecordOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, jsonStringOfStringRecordOfNumbers); + }); + it("`JsonString>`", () => { + const { stringified, out } = stringifyThenParse( + jsonStringOfStringRecordOfNumberOrUndefined, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, jsonStringOfStringRecordOfNumberOrUndefined); + }); + it("`JsonString`", () => { + const { stringified, out } = stringifyThenParse(jsonStringOfBigInt); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, jsonStringOfBigInt); + }); + it("`JsonString`", () => { + const { stringified, out } = stringifyThenParse(jsonStringOfUnknown); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, jsonStringOfUnknown); + }); + }); + + describe("supports literal types", () => { + it("`true`", () => { + const { stringified, out } = stringifyThenParse(true); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, true); + }); + it("`false`", () => { + const { stringified, out } = stringifyThenParse(false); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, false); + }); + it("`0`", () => { + const { stringified, out } = stringifyThenParse(0); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, 0); + }); + it('"string"', () => { + const { stringified, out } = stringifyThenParse("string"); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, "string"); + }); + it("`null`", () => { + const { stringified, out } = stringifyThenParse(null); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, null); + }); + it("object with literals", () => { + const { stringified, out } = stringifyThenParse(objectWithLiterals); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithLiterals); + }); + it("array of literals", () => { + const { stringified, out } = stringifyThenParse(arrayOfLiterals); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, arrayOfLiterals); + }); + it("tuple of literals", () => { + const { stringified, out } = stringifyThenParse(tupleWithLiterals); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, tupleWithLiterals); + }); + it("specific numeric enum value", () => { + const { stringified, out } = stringifyThenParse(NumericEnum.two); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, NumericEnum.two); + }); + it("specific string enum value", () => { + const { stringified, out } = stringifyThenParse(StringEnum.b); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, StringEnum.b); + }); + it("specific const heterogenous enum value", () => { + const { stringified, out } = stringifyThenParse(ConstHeterogenousEnum.zero); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, ConstHeterogenousEnum.zero); + }); + it("specific computed enum value", () => { + const { stringified, out } = stringifyThenParse(ComputedEnum.computed); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, ComputedEnum.computed); + }); + }); + + describe("supports array types", () => { + it("array of `number`s", () => { + const { stringified, out } = stringifyThenParse(arrayOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, arrayOfNumbers); + }); + it("readonly array of `number`s", () => { + const { stringified, out } = stringifyThenParse(readonlyArrayOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, readonlyArrayOfNumbers); + }); + it("readonly array of simple objects", () => { + const { stringified, out } = stringifyThenParse(readonlyArrayOfObjects); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, readonlyArrayOfObjects); + }); + }); + + describe("supports object types", () => { + it("empty object", () => { + const { stringified, out } = stringifyThenParse(emptyObject); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, emptyObject); + }); + + it("object with `never`", () => { + const { stringified, out } = stringifyThenParse(objectWithNever); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + // @ts-expect-error `out` removes `never` type and thus difference is expected. + assertIdenticalTypes(out, objectWithNever); + assertIdenticalTypes(out, {}); + }); + + it("object with `boolean`", () => { + const { stringified, out } = stringifyThenParse(objectWithBoolean); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithBoolean); + }); + it("object with `number`", () => { + const { stringified, out } = stringifyThenParse(objectWithNumber); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithNumber); + }); + it("object with `string`", () => { + const { stringified, out } = stringifyThenParse(objectWithString); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithString); + }); + + it("object with number key", () => { + const { stringified, out } = stringifyThenParse(objectWithNumberKey); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithNumberKey); + }); + + it("object with array of `number`s", () => { + const { stringified, out } = stringifyThenParse(objectWithArrayOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithArrayOfNumbers); + }); + it("readonly array of `number`s", () => { + const { stringified, out } = stringifyThenParse(objectWithReadonlyArrayOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithReadonlyArrayOfNumbers); + }); + + it("object with branded `number`", () => { + const { stringified, out } = stringifyThenParse(objectWithBrandedNumber); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithBrandedNumber); + }); + it("object with branded `string`", () => { + const { stringified, out } = stringifyThenParse(objectWithBrandedString); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithBrandedString); + }); + + it("`string` indexed record of `number`s", () => { + const { stringified, out } = stringifyThenParse(stringRecordOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, stringRecordOfNumbers); + }); + it("`string`|`number` indexed record of `string`s", () => { + const { stringified, out } = stringifyThenParse(stringOrNumberRecordOfStrings); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, stringOrNumberRecordOfStrings); + }); + it("`string`|`number` indexed record of objects", () => { + const { stringified, out } = stringifyThenParse(stringOrNumberRecordOfObjects); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, stringOrNumberRecordOfObjects); + }); + it("templated record of `numbers`", () => { + const { stringified, out } = stringifyThenParse(templatedRecordOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, templatedRecordOfNumbers); + }); + it("`string` indexed record of `number`|`string`s with known properties", () => { + const { stringified, out } = stringifyThenParse( + stringRecordOfNumbersOrStringsWithKnownProperties, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes(out, stringRecordOfNumbersOrStringsWithKnownProperties); + }); + it("`string`|`number` indexed record of `strings` with known `number` property (unassignable)", () => { + const { stringified, out } = stringifyThenParse( + stringOrNumberRecordOfStringWithKnownNumber, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, stringOrNumberRecordOfStringWithKnownNumber); + }); + + it("branded-`string` indexed of `boolean`s", () => { + const { stringified, out } = stringifyThenParse(brandedStringIndexOfBooleans); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, brandedStringIndexOfBooleans); + }); + it("branded-`string` alias indexed of `boolean`s", () => { + const { stringified, out } = stringifyThenParse(brandedStringAliasIndexOfBooleans); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, brandedStringAliasIndexOfBooleans); + }); + it("branded-`string` record of `boolean`s", () => { + const { stringified, out } = stringifyThenParse(brandedStringRecordOfBooleans); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, brandedStringRecordOfBooleans); + }); + it("branded-`string` alias record of `boolean`s", () => { + const { stringified, out } = stringifyThenParse(brandedStringAliasRecordOfBooleans); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, brandedStringAliasRecordOfBooleans); + }); + it("branded-`string` indexed of `number`s", () => { + const { stringified, out } = stringifyThenParse(brandedStringIndexOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, brandedStringIndexOfNumbers); + }); + it("branded-`string` alias indexed of `number`s", () => { + const { stringified, out } = stringifyThenParse(brandedStringAliasIndexOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, brandedStringAliasIndexOfNumbers); + }); + it("branded-`string` record of `number`s", () => { + const { stringified, out } = stringifyThenParse(brandedStringRecordOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, brandedStringRecordOfNumbers); + }); + it("branded-`string` alias record of `number`s", () => { + const { stringified, out } = stringifyThenParse(brandedStringAliasRecordOfNumbers); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, brandedStringAliasRecordOfNumbers); + }); + + it("object with possible type recursion through union", () => { + const { stringified, out } = stringifyThenParse(objectWithPossibleRecursion); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithPossibleRecursion); + }); + it("object with optional type recursion", () => { + const { stringified, out } = stringifyThenParse(objectWithOptionalRecursion); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithOptionalRecursion); + }); + it("object with deep type recursion", () => { + const { stringified, out } = stringifyThenParse(objectWithEmbeddedRecursion); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithEmbeddedRecursion); + }); + it("object with alternating type recursion", () => { + const { stringified, out } = stringifyThenParse(objectWithAlternatingRecursion); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithAlternatingRecursion); + }); + + it("simple non-null object json (NonNullJsonObjectWith)", () => { + const { stringified, out } = stringifyThenParse(jsonObject); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, jsonObject); + }); + it("simple read-only non-null object json (ReadonlyNonNullJsonObjectWith)", () => { + const { stringified, out } = stringifyThenParse(immutableJsonObject); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, immutableJsonObject); + }); + + it("non-const enums", () => { + // Note: typescript doesn't do a great job checking that a filtered type satisfies an enum + // type. The numeric indices are not checked. So far most robust inspection is manually + // after any change. + const { stringified: resultNumeric, out: outNumeric } = + stringifyThenParse(NumericEnum); + assertIdenticalTypes( + resultNumeric, + createInstanceOf>(), + ); + assertIdenticalTypes(outNumeric, NumericEnum); + const { stringified: resultString, out: outString } = stringifyThenParse(StringEnum); + assertIdenticalTypes(resultString, createInstanceOf>()); + assertIdenticalTypes(outString, StringEnum); + const { stringified: resultComputed, out: outComputed } = + stringifyThenParse(ComputedEnum); + assertIdenticalTypes( + resultComputed, + createInstanceOf>(), + ); + assertIdenticalTypes(outComputed, ComputedEnum); + }); + + it("object with `readonly`", () => { + const { stringified, out } = stringifyThenParse(objectWithReadonly); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithReadonly); + }); + + it("object with getter implemented via value", () => { + const { stringified, out } = stringifyThenParse(objectWithGetterViaValue); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithGetterViaValue); + }); + it("object with setter implemented via value", () => { + const { stringified, out } = stringifyThenParse(objectWithSetterViaValue); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithSetterViaValue); + }); + it("object with matched getter and setter implemented via value", () => { + const { stringified, out } = stringifyThenParse( + objectWithMatchedGetterAndSetterPropertyViaValue, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes(out, objectWithMatchedGetterAndSetterPropertyViaValue); + }); + it("object with mismatched getter and setter implemented via value", () => { + const { stringified, out } = stringifyThenParse( + objectWithMismatchedGetterAndSetterPropertyViaValue, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes(out, objectWithMismatchedGetterAndSetterPropertyViaValue); + }); + + describe("class instance (losing 'instanceof' nature)", () => { + it("with public data (just cares about data)", () => { + const { stringified, out } = stringifyThenParse(classInstanceWithPublicData, { + public: "public", + }); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, classInstanceWithPublicData); + assert.ok( + classInstanceWithPublicData instanceof ClassWithPublicData, + "classInstanceWithPublicData is an instance of ClassWithPublicData", + ); + assert.ok( + !(out instanceof ClassWithPublicData), + "out is not an instance of ClassWithPublicData", + ); + }); + describe("with `ignore-inaccessible-members`", () => { + it("with private method ignores method", () => { + const { stringified, out } = stringifyIgnoringInaccessibleMembersThenParse( + classInstanceWithPrivateMethod, + { + public: "public", + }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + public: string; + }>(), + ); + // @ts-expect-error getSecret is missing, but required + out satisfies typeof classInstanceWithPrivateMethod; + // @ts-expect-error getSecret is missing, but required + assertIdenticalTypes(out, classInstanceWithPrivateMethod); + assert.ok( + classInstanceWithPrivateMethod instanceof ClassWithPrivateMethod, + "classInstanceWithPrivateMethod is an instance of ClassWithPrivateMethod", + ); + assert.ok( + !(out instanceof ClassWithPrivateMethod), + "out is not an instance of ClassWithPrivateMethod", + ); + }); + it("with private getter ignores getter", () => { + const { stringified, out } = stringifyIgnoringInaccessibleMembersThenParse( + classInstanceWithPrivateGetter, + { + public: "public", + }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + public: string; + }>(), + ); + // @ts-expect-error secret is missing, but required + out satisfies typeof classInstanceWithPrivateGetter; + // @ts-expect-error secret is missing, but required + assertIdenticalTypes(out, classInstanceWithPrivateGetter); + assert.ok( + classInstanceWithPrivateGetter instanceof ClassWithPrivateGetter, + "classInstanceWithPrivateGetter is an instance of ClassWithPrivateGetter", + ); + assert.ok( + !(out instanceof ClassWithPrivateGetter), + "out is not an instance of ClassWithPrivateGetter", + ); + }); + it("with private setter ignores setter", () => { + const { stringified, out } = stringifyIgnoringInaccessibleMembersThenParse( + classInstanceWithPrivateSetter, + { + public: "public", + }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + public: string; + }>(), + ); + // @ts-expect-error secret is missing, but required + out satisfies typeof classInstanceWithPrivateSetter; + // @ts-expect-error secret is missing, but required + assertIdenticalTypes(out, classInstanceWithPrivateSetter); + assert.ok( + classInstanceWithPrivateSetter instanceof ClassWithPrivateSetter, + "classInstanceWithPrivateSetter is an instance of ClassWithPrivateSetter", + ); + assert.ok( + !(out instanceof ClassWithPrivateSetter), + "out is not an instance of ClassWithPrivateSetter", + ); + }); + }); + }); + + describe("object with optional property", () => { + it("without property", () => { + const { stringified, out } = stringifyThenParse(objectWithOptionalNumberNotPresent); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithOptionalNumberNotPresent); + }); + it("with undefined value", () => { + const { stringified, out } = stringifyThenParse( + objectWithOptionalNumberUndefined, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithOptionalNumberUndefined); + }); + it("with defined value", () => { + const { stringified, out } = stringifyThenParse(objectWithOptionalNumberDefined); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithOptionalNumberDefined); + }); + }); + + describe("opaque Json types", () => { + it("opaque serializable object", () => { + const { stringified, out } = stringifyThenParse(opaqueSerializableObject); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + // @ts-expect-error In this case, `out` has a unique `OpaqueJsonDeserialized` result. + assertIdenticalTypes(out, opaqueSerializableObject); + assertIdenticalTypes( + out, + createInstanceOf>(), + ); + }); + it("opaque deserialized object", () => { + const { stringified, out } = stringifyThenParse(opaqueDeserializedObject); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, opaqueDeserializedObject); + }); + it("opaque serializable and deserialized object", () => { + const { stringified, out } = stringifyThenParse( + opaqueSerializableAndDeserializedObject, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + // @ts-expect-error In this case, `out` has a unique `OpaqueJsonDeserialized` result. + assertIdenticalTypes(out, opaqueSerializableAndDeserializedObject); + assertIdenticalTypes( + out, + createInstanceOf>(), + ); + }); + it("opaque serializable unknown", () => { + const { stringified, out } = stringifyThenParse(opaqueSerializableUnknown); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + // @ts-expect-error In this case, `out` has a unique `OpaqueJsonDeserialized` result. + assertIdenticalTypes(out, opaqueSerializableUnknown); + assertIdenticalTypes(out, createInstanceOf>()); + }); + it("opaque deserialized unknown", () => { + const { stringified, out } = stringifyThenParse(opaqueDeserializedUnknown); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, opaqueDeserializedUnknown); + }); + it("opaque serializable and deserialized unknown", () => { + const { stringified, out } = stringifyThenParse( + opaqueSerializableAndDeserializedUnknown, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + // @ts-expect-error In this case, `out` has a unique `OpaqueJsonDeserialized` result. + assertIdenticalTypes(out, opaqueSerializableAndDeserializedUnknown); + assertIdenticalTypes(out, createInstanceOf>()); + }); + it("object with opaque serializable unknown", () => { + const { stringified, out } = stringifyThenParse(objectWithOpaqueSerializableUnknown); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + // @ts-expect-error In this case, `out` has a unique `OpaqueJsonDeserialized` result. + assertIdenticalTypes(out, objectWithOpaqueSerializableUnknown); + assertIdenticalTypes( + out, + createInstanceOf<{ opaque: OpaqueJsonDeserialized }>(), + ); + }); + it("object with opaque deserialized unknown", () => { + const { stringified, out } = stringifyThenParse(objectWithOpaqueDeserializedUnknown); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithOpaqueDeserializedUnknown); + }); + it("recursive type with opaque serializable unknown", () => { + const { stringified, out } = stringifyThenParse( + opaqueSerializableInRecursiveStructure, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + // @ts-expect-error In this case, `out` has a unique `OpaqueJsonDeserialized` result. + assertIdenticalTypes(out, opaqueSerializableInRecursiveStructure); + assertIdenticalTypes( + out, + createInstanceOf(), + ); + }); + it("recursive type with opaque deserialized unknown", () => { + const { stringified, out } = stringifyThenParse( + opaqueDeserializedInRecursiveStructure, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, opaqueDeserializedInRecursiveStructure); + }); + it("recursive type with opaque serializable and deserialized unknown", () => { + const { stringified, out } = stringifyThenParse( + opaqueSerializableAndDeserializedInRecursiveStructure, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + // @ts-expect-error In this case, `out` has a unique `OpaqueJsonDeserialized` result. + assertIdenticalTypes(out, opaqueSerializableAndDeserializedInRecursiveStructure); + assertIdenticalTypes( + out, + createInstanceOf(), + ); + }); + it("recursive branded indexed object with OpaqueJsonDeserialized", () => { + const { stringified, out } = stringifyThenParse(datastore); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, datastore); + }); + }); + }); + + describe("supports union types", () => { + it("simple json (JsonTypeWith)", () => { + const { stringified, out } = stringifyThenParse(simpleJson); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, simpleJson); + }); + it("simple read-only json (ReadonlyJsonTypeWith)", () => { + const { stringified, out } = stringifyThenParse(simpleImmutableJson); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, simpleImmutableJson); + }); + }); + + describe("with NOT fully supported object types", () => { + // This is a reasonable limitation. The type system doesn't have a way to be + // sure if there is a self reference or not. + it("object with self reference throws on serialization", () => { + assert.throws( + () => JsonStringify(objectWithSelfReference), + new TypeError( + "Converting circular structure to JSON\n --> starting at object with constructor 'Object'\n --- property 'recursive' closes the circle", + ), + ); + }); + + // These cases are demonstrating defects within the current implementation. + // They show "allowed" incorrect use and the unexpected results. + describe("known defect expectations", () => { + describe("getters and setters allowed but do not propagate", () => { + it("object with `readonly` implemented via getter", () => { + const { stringified, out } = stringifyThenParse(objectWithReadonlyViaGetter, {}); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithReadonlyViaGetter); + }); + + it("object with getter", () => { + const { stringified, out } = stringifyThenParse(objectWithGetter, {}); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithGetter); + }); + + it("object with setter", () => { + const { stringified, out } = stringifyThenParse(objectWithSetter, {}); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithSetter); + }); + + it("object with matched getter and setter", () => { + const { stringified, out } = stringifyThenParse( + objectWithMatchedGetterAndSetterProperty, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithMatchedGetterAndSetterProperty); + }); + + it("object with mismatched getter and setter", () => { + const { stringified, out } = stringifyThenParse( + objectWithMismatchedGetterAndSetterProperty, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes(out, objectWithMismatchedGetterAndSetterProperty); + }); + }); + + describe("class instance", () => { + describe("with `ignore-inaccessible-members`", () => { + it("with private data ignores private data (that propagates)", () => { + const { stringified, out } = stringifyIgnoringInaccessibleMembersThenParse( + classInstanceWithPrivateData, + { + public: "public", + secret: 0, + }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + // @ts-expect-error secret is missing, but required + out satisfies typeof classInstanceWithPrivateData; + assertIdenticalTypes(out, createInstanceOf<{ public: string }>()); + // @ts-expect-error secret is missing, but required + assertIdenticalTypes(out, classInstanceWithPrivateData); + assert.ok( + classInstanceWithPrivateData instanceof ClassWithPrivateData, + "classInstanceWithPrivateData is an instance of ClassWithPrivateData", + ); + assert.ok( + !(out instanceof ClassWithPrivateData), + "out is not an instance of ClassWithPrivateData", + ); + }); + }); + }); + + it("sparse array of supported types", () => { + const { stringified, out } = stringifyThenParse(arrayOfNumbersSparse, [ + 0, + null, + null, + 3, + ]); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, arrayOfNumbersSparse); + }); + + it("object with sparse array of supported types", () => { + const { stringified, out } = stringifyThenParse(objectWithArrayOfNumbersSparse, { + arrayOfNumbersSparse: [0, null, null, 3], + }); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, objectWithArrayOfNumbersSparse); + }); + }); + }); + }); + + describe("invalid input usage", () => { + describe("assumptions", () => { + it("const enums are never readable", () => { + // ... and thus don't need accounted for by JsonDeserialized. + + const enum LocalConstHeterogenousEnum { + zero, + a = "a", + } + + assert.throws(() => { + // @ts-expect-error `const enums` are not accessible for reading + stringifyThenParse(LocalConstHeterogenousEnum); + }, new ReferenceError("LocalConstHeterogenousEnum is not defined")); + + /** + * In CommonJs, an imported const enum becomes undefined. Only + * local const enums are inaccessible. To avoid building special + * support for both ESM and CommonJS, this helper allows calling + * with undefined (for CommonJS) and simulates the error that + * is expected on ESM. + * Importantly `undefined` is not expected to be serializable and + * thus is always a problem. + */ + function doNothingPassThru(v: T): never { + if (v === undefined) { + throw new ReferenceError(`ConstHeterogenousEnum is not defined`); + } + throw new Error("Internal test error - should not reach here"); + } + + assert.throws(() => { + // @ts-expect-error `const enums` are not accessible for reading + doNothingPassThru(ConstHeterogenousEnum); + }, new ReferenceError("ConstHeterogenousEnum is not defined")); + }); + }); + + describe("unsupported types cause compiler error", () => { + it("`undefined`", () => { + const stringified = JsonStringify( + // @ts-expect-error `undefined` is not supported + undefined, + ); + assert.equal(stringified, undefined, "undefined is not serializable"); + }); + it("`unknown`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `unknown` is not supported (expects `JsonTypeWith | OpaqueJsonSerializable`) + {} as unknown, + ); // {} value is actually supported; so, no runtime error. + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, createInstanceOf>()); + }); + it("`symbol`", () => { + const stringified = JsonStringify( + // @ts-expect-error `symbol` is not supported + symbol, + ); + assert.equal(stringified, undefined, "symbol is not serializable"); + }); + it("`unique symbol`", () => { + const stringified = JsonStringify( + // @ts-expect-error [unique] `symbol` is not supported + uniqueSymbol, + ); + assert.equal(stringified, undefined, "uniqueSymbol is not serializable"); + }); + it("`bigint`", () => { + assert.throws( + () => + JsonStringify( + // @ts-expect-error `bigint` is not supported + bigint, + ), + new TypeError("Do not know how to serialize a BigInt"), + ); + }); + it("function", () => { + const stringified = JsonStringify( + // @ts-expect-error `Function` is not supported + aFunction, + ); + assert.equal(stringified, undefined, "aFunction is not serializable"); + // Keep this assert at end of scope to avoid assertion altering type + const varTypeof = typeof aFunction; + assert(varTypeof === "function", "plain function is a function at runtime"); + }); + it("function with supported properties", () => { + const stringified = JsonStringify( + // @ts-expect-error `Function & {...}` is not supported + functionWithProperties, + ); + assert.equal(stringified, undefined, "functionWithProperties is not serializable"); + // Keep this assert at end of scope to avoid assertion altering type + const varTypeof = typeof functionWithProperties; + assert(varTypeof === "function", "function with properties is a function at runtime"); + }); + it("object and function", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `{...} & Function` is not supported + objectAndFunction, + { property: 6 }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ property: number }>()); + // Keep this assert at end of scope to avoid assertion altering type + const varTypeof = typeof objectAndFunction; + assert(varTypeof === "object", "object assigned a function is an object at runtime"); + }); + it("object with function with supported properties", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `{ function: Function & {...}}` is not supported (becomes `{ function: never }`) + objectWithFunctionWithProperties, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + function?: { + property: number; + }; + }>(), + ); + }); + it("object with object and function", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `{ object: {...} & Function }` is not supported (becomes `{ object: never }`) + objectWithObjectAndFunction, + { object: { property: 6 } }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + object?: { + property: number; + }; + }>(), + ); + }); + it("function with class instance with private data", () => { + const stringified = JsonStringify( + // @ts-expect-error SerializationErrorPerNonPublicProperties + functionObjectWithPrivateData, + ); + assert.equal( + stringified, + undefined, + "functionObjectWithPrivateData is not serializable", + ); + // Keep this assert at end of scope to avoid assertion altering type + const varTypeof = typeof functionObjectWithPrivateData; + assert( + varTypeof === "function", + "function that is also a class instance is a function at runtime", + ); + }); + it("function with class instance with public data", () => { + const stringified = JsonStringify( + // @ts-expect-error `Function & {...}` is not supported + functionObjectWithPublicData, + ); + assert.equal( + stringified, + undefined, + "functionObjectWithPublicData is not serializable", + ); + }); + it("class instance with private data and is function", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error SerializationErrorPerNonPublicProperties + classInstanceWithPrivateDataAndIsFunction, + { + public: "public", + // secret is also not allowed but is present + secret: 0, + }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ public: string }>()); + // Keep this assert at end of scope to avoid assertion altering type + const varTypeof = typeof classInstanceWithPrivateDataAndIsFunction; + assert( + varTypeof === "object", + "class instance that is also a function is an object at runtime", + ); + }); + it("class instance with public data and is function", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `Function & {...}` is not supported + classInstanceWithPublicDataAndIsFunction, + { public: "public" }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ public: string }>()); + }); + it("`object` (plain object)", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `object` is not supported (expects `NonNullJsonObjectWith`) + object, + // object's value is actually supported; so, no runtime error. + ); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, createInstanceOf>()); + }); + it("`void`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `void` is not supported + voidValue, + // voidValue is actually `null`; so, no runtime error. + ); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, createInstanceOf()); + }); + it("branded `object`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error SerializationErrorPerNonPublicProperties + brandedObject, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + // Ideally there could be a transformation to JsonTypeWith but + // `object` intersected with branding (which is an object) is just the branding. + assertIdenticalTypes(out, emptyObject); + }); + it("branded object with `string`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error SerializationErrorPerNonPublicProperties + brandedObjectWithString, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ string: string }>()); + }); + + describe("unions with unsupported primitive types", () => { + it("`string | symbol`", () => { + const stringified = JsonStringify( + // @ts-expect-error `string | symbol` is not assignable to `string` + stringOrSymbol, + ); + assert.equal(stringified, undefined, "stringOrSymbol is not serializable"); + }); + it("`bigint | string`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `string | bigint` is not assignable to `string` + bigintOrString, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf()); + }); + it("`bigint | symbol`", () => { + const stringified = JsonStringify( + // @ts-expect-error `bigint | symbol` is not assignable to `never` + bigintOrSymbol, + ); + assert.equal(stringified, undefined, "bigintOrSymbol is not serializable"); + }); + it("`number | bigint | symbol`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `number | bigint | symbol` is not assignable to `number` + numberOrBigintOrSymbol, + 7, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf()); + }); + }); + + describe("array", () => { + it("array of `bigint`s", () => { + assert.throws( + () => + JsonStringify( + // @ts-expect-error 'bigint' is not supported (becomes 'never') + arrayOfBigints, + ), + new TypeError("Do not know how to serialize a BigInt"), + ); + }); + it("array of `symbol`s", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'symbol' is not supported (becomes 'never') + arrayOfSymbols, + [null], + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf()); + }); + it("array of `unknown`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'unknown[]' is not assignable to parameter of type '(JsonTypeWith | OpaqueJsonSerializable)[]' + arrayOfUnknown, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf[]>()); + }); + it("array of functions", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `Function` is not supported (becomes 'never') + arrayOfFunctions, + [null], + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf()); + }); + it("array of functions with properties", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'Function & {...}' is not supported (becomes 'never') + arrayOfFunctionsWithProperties, + [null], + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<({ property: number } | null)[]>()); + }); + it("array of objects and functions", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error '{...} & Function' is not supported (becomes 'never') + arrayOfObjectAndFunctions, + [{ property: 6 }], + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<({ property: number } | null)[]>()); + }); + it("array of `number | undefined`s", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'undefined' is not supported (becomes 'SerializationErrorPerUndefinedArrayElement') + arrayOfNumbersOrUndefined, + [0, null, 2], + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<(number | null)[]>()); + }); + it("array of `bigint` or basic object", () => { + assert.throws( + () => + JsonStringify( + // @ts-expect-error 'bigint' is not supported (becomes 'never') + arrayOfBigintOrObjects, + ), + new TypeError("Do not know how to serialize a BigInt"), + ); + }); + it("array of `symbol` or basic object", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'symbol' is not supported (becomes 'never') + arrayOfSymbolOrObjects, + [null], + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<({ property: string } | null)[]>()); + }); + it("array of `bigint | symbol`s", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'bigint | symbol' is not assignable to 'never' + arrayOfBigintOrSymbols, + [null], + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf()); + }); + it("array of `number | bigint | symbol`s", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'number | bigint | symbol' is not assignable to 'number' + arrayOfNumberBigintOrSymbols, + [7], + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<(number | null)[]>()); + }); + }); + + describe("object", () => { + it("object with exactly `bigint`", () => { + assert.throws( + () => + JsonStringify( + // @ts-expect-error `bigint` is not supported + objectWithBigint, + ), + new TypeError("Do not know how to serialize a BigInt"), + ); + }); + it("object with optional `bigint`", () => { + assert.throws( + () => + JsonStringify( + // @ts-expect-error `bigint` is not supported + objectWithOptionalBigint, + ), + new TypeError("Do not know how to serialize a BigInt"), + ); + }); + it("object with exactly `symbol`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `symbol` is not supported + objectWithSymbol, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, emptyObject); + }); + it("object with optional `symbol`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `symbol` is not supported + objectWithOptionalSymbol, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, emptyObject); + }); + it("object with exactly `function`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `Function` is not supported + objectWithFunction, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, emptyObject); + }); + it("object with exactly `Function | symbol`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `symbol | (() => void)` is not supported + objectWithFunctionOrSymbol, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, emptyObject); + }); + it("object with exactly `string | symbol`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `string | symbol` is not assignable to `string` + objectWithStringOrSymbol, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + stringOrSymbol?: string; + }>(), + ); + }); + it("object with exactly `bigint | string`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `bigint | string` is not assignable to `string` + objectWithBigintOrString, + // value is a string; so no runtime error. + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + bigintOrString: string; + }>(), + ); + }); + it("object with exactly `bigint | symbol`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `bigint | symbol` is not assignable to `never` + objectWithBigintOrSymbol, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, emptyObject); + }); + it("object with exactly `number | bigint | symbol`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `number | bigint | symbol` is not assignable to `number` + objectWithNumberOrBigintOrSymbol, + { numberOrBigintOrSymbol: 7 }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + numberOrBigintOrSymbol?: number; + }>(), + ); + }); + it("object with optional `unknown`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error Type `unknown` is not assignable to type `JsonTypeWith | OpaqueJsonSerializable` + objectWithOptionalUnknown, + { optUnknown: "value" }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + optUnknown?: JsonTypeWith; + }>(), + ); + }); + it("`string` indexed record of `symbol | boolean`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error Type 'symbol | boolean' is not assignable to type 'boolean' + stringRecordOfSymbolOrBoolean, + { boolean }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf>()); + }); + + it("object with array of `bigint`s", () => { + assert.throws( + () => + JsonStringify( + // @ts-expect-error 'bigint' is not supported (becomes 'never') + objectWithArrayOfBigints, + ), + new TypeError("Do not know how to serialize a BigInt"), + ); + }); + it("object with array of `symbol`s", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'symbol' is not supported (becomes 'never') + objectWithArrayOfSymbols, + { arrayOfSymbols: [null] }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ arrayOfSymbols: null[] }>()); + }); + it("object with array of `unknown`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'unknown[]' is not assignable to parameter of type '(JsonTypeWith | OpaqueJsonSerializable)[]' + objectWithArrayOfUnknown, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ arrayOfUnknown: JsonTypeWith[] }>(), + ); + }); + it("object with array of functions", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `Function` is not supported (becomes 'never') + objectWithArrayOfFunctions, + { arrayOfFunctions: [null] }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ arrayOfFunctions: null[] }>()); + }); + it("object with array of functions with properties", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'Function & {...}' is not supported (becomes 'never') + objectWithArrayOfFunctionsWithProperties, + { arrayOfFunctionsWithProperties: [null] }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + arrayOfFunctionsWithProperties: ({ + property: number; + } | null)[]; + }>(), + ); + }); + it("object with array of objects and functions", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error '{...} & Function' is not supported (becomes 'never') + objectWithArrayOfObjectAndFunctions, + { arrayOfObjectAndFunctions: [{ property: 6 }] }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + arrayOfObjectAndFunctions: ({ + property: number; + } | null)[]; + }>(), + ); + }); + it("object with array of `number | undefined`s", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'undefined' is not supported (becomes 'SerializationErrorPerUndefinedArrayElement') + objectWithArrayOfNumbersOrUndefined, + { arrayOfNumbersOrUndefined: [0, null, 2] }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ arrayOfNumbersOrUndefined: (number | null)[] }>(), + ); + }); + it("object with array of `bigint` or basic object", () => { + assert.throws( + () => + JsonStringify( + // @ts-expect-error 'bigint' is not supported (becomes 'never') + objectWithArrayOfBigintOrObjects, + ), + new TypeError("Do not know how to serialize a BigInt"), + ); + }); + it("object with array of `symbol` or basic object", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'symbol' is not supported (becomes 'never') + objectWithArrayOfSymbolOrObjects, + { arrayOfSymbolOrObjects: [null] }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + arrayOfSymbolOrObjects: ({ + property: string; + } | null)[]; + }>(), + ); + }); + it("object with array of `bigint | symbol`s", () => { + const objectWithArrayOfBigintOrSymbols = { + arrayOfBigintOrSymbols: [bigintOrSymbol], + }; + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'bigint | symbol' is not assignable to 'never' + objectWithArrayOfBigintOrSymbols, + { arrayOfBigintOrSymbols: [null] }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ arrayOfBigintOrSymbols: null[] }>()); + }); + + it("object with `symbol` key", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error `symbol` key is not supported (property type becomes `never`) + objectWithSymbolKey, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, emptyObject); + }); + it("object with [unique] symbol key", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error symbol key is not supported (property type becomes `never`) + objectWithUniqueSymbolKey, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, emptyObject); + }); + + it("`string` indexed record of `unknown`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error not assignable to parameter of type '{ [x: string]: JsonTypeWith | OpaqueJsonSerializable; }'. + stringRecordOfUnknown, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf>>()); + }); + it("`Partial<>` `string` indexed record of `unknown`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error not assignable to parameter of type '{ [x: string]: JsonTypeWith | OpaqueJsonSerializable; }'. + partialStringRecordOfUnknown, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf>>()); + }); + + it("`Partial<>` `string` indexed record of `numbers`", () => { + // Warning: as of TypeScript 5.8.2, a Partial<> of an indexed type + // gains `| undefined` even under exactOptionalPropertyTypes=true. + // Preferred result is that there is no change applying Partial<>. + // Allowing `undefined` is possible if all indexed properties are + // identifiable. But rather than that, an implementation of `Partial<>` + // that doesn't add `| undefined` for index signatures would be preferred. + const { stringified, out } = stringifyThenParse( + // @ts-expect-error not assignable to type '{ "error required property may not allow `undefined` value": never; }' + partialStringRecordOfNumbers, + { key1: 0 }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf>()); + }); + it("`Partial<>` templated record of `numbers`", () => { + // Warning: as of TypeScript 5.8.2, a Partial<> of an indexed type + // gains `| undefined` even under exactOptionalPropertyTypes=true. + // Preferred result is that there is no change applying Partial<>. + // Allowing `undefined` is possible if all indexed properties are + // identifiable. But rather than that, an implementation of `Partial<>` + // that doesn't add `| undefined` for index signatures would be preferred. + const { stringified, out } = stringifyThenParse( + // @ts-expect-error not assignable to type '{ "error required property may not allow `undefined` value": never; }' + partialTemplatedRecordOfNumbers, + { key1: 0 }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf>()); + }); + + it("object with recursion and `symbol`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'ObjectWithSymbolOrRecursion' is not assignable to parameter of type '{ recurse: ObjectWithSymbolOrRecursion; }' (`symbol` becomes `never`) + objectWithSymbolOrRecursion, + { recurse: {} }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + recurse?: OpaqueJsonDeserialized; + }>(), + ); + }); + + it("function object with recursion", () => { + const stringified = JsonStringify( + // @ts-expect-error 'SelfRecursiveFunctionWithProperties' is not assignable to parameter of type 'never' (function even with properties becomes `never`) + selfRecursiveFunctionWithProperties, + ); + assert.equal( + stringified, + undefined, + "selfRecursiveFunctionWithProperties is not serializable", + ); + // Keep this assert at end of scope to avoid assertion altering + const varTypeof = typeof selfRecursiveFunctionWithProperties; + assert( + varTypeof === "function", + "self recursive function with properties is a function at runtime", + ); + }); + it("object and function with recursion", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'SelfRecursiveObjectAndFunction' is not assignable to parameter of type 'never' (function even with properties becomes `never`) + selfRecursiveObjectAndFunction, + { recurse: {} }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + recurse?: OpaqueJsonDeserialized; + }>(), + ); + // Keep this assert at end of scope to avoid assertion altering + const varTypeof = typeof selfRecursiveObjectAndFunction; + assert( + varTypeof === "object", + "self recursive object and function is an object at runtime", + ); + }); + + it("nested function object with recursion", () => { + const objectWithNestedFunctionWithPropertiesAndRecursion = { + outerFnOjb: selfRecursiveFunctionWithProperties, + }; + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'SelfRecursiveFunctionWithProperties' is not assignable to parameter of type 'never' (function even with properties becomes `never`) + objectWithNestedFunctionWithPropertiesAndRecursion, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + outerFnOjb?: { + recurse?: OpaqueJsonDeserialized; + }; + }>(), + ); + }); + it("nested object and function with recursion", () => { + const objectWithNestedObjectAndFunctionWithRecursion = { + outerFnOjb: selfRecursiveObjectAndFunction, + }; + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'SelfRecursiveObjectAndFunction' is not assignable to parameter of type 'never' (function even with properties becomes `never`) + objectWithNestedObjectAndFunctionWithRecursion, + { outerFnOjb: { recurse: {} } }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + outerFnOjb?: { + recurse?: OpaqueJsonDeserialized; + }; + }>(), + ); + }); + + it("object with inherited recursion extended with unsupported properties", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error 'ObjectInheritingOptionalRecursionAndWithNestedSymbol' is not assignable to parameter of type '...' (symbol at complex.symbol becomes `never`) + objectInheritingOptionalRecursionAndWithNestedSymbol, + { recursive: { recursive: { recursive: {} } }, complex: { number: 0 } }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + complex: { + number: number; + }; + recursive?: { + recursive?: ObjectWithOptionalRecursion; + }; + }>(), + ); + }); + + describe("object with `undefined`", () => { + it("as exact property type", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error not assignable to type '{ "error required property may not allow `undefined` value": never; }' + objectWithUndefined, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, emptyObject); + }); + it("in union property", () => { + const { stringified: resultUndefined, out: outUndefined } = stringifyThenParse( + // @ts-expect-error not assignable to type '{ "error required property may not allow `undefined` value": never; }' + objectWithNumberOrUndefinedUndefined, + {}, + ); + assertIdenticalTypes( + resultUndefined, + createInstanceOf>(), + ); + assertIdenticalTypes(outUndefined, createInstanceOf<{ numOrUndef?: number }>()); + const { stringified: resultNumbered, out: outNumbered } = stringifyThenParse( + // @ts-expect-error not assignable to `{ "error required property may not allow `undefined` value": never; }` + objectWithNumberOrUndefinedNumbered, + ); + assertIdenticalTypes(resultNumbered, resultUndefined); + assertIdenticalTypes(outNumbered, outUndefined); + }); + it("as exact property type of `string` indexed record", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error not assignable to type '{ "error required property may not allow `undefined` value": never; }' + stringRecordOfUndefined, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, emptyObject); + }); + it("as exact property type of `string` indexed record intersected with known `number` property (unassignable)", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error Type 'undefined' is not assignable to type '{ "error required property may not allow `undefined` value": never; }' + stringOrNumberRecordOfUndefinedWithKnownNumber, + { knownNumber: 4 }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes(out, createInstanceOf<{ knownNumber: number }>()); + }); + + it("`| number` in string indexed record", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error Type 'undefined' is not assignable to type '{ "error required property may not allow `undefined` value": never; }' + stringRecordOfNumberOrUndefined, + { number }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf>()); + }); + + it("`| true` in branded-`string` alias index", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error Type 'undefined' is not assignable to type '{ "error required property may not allow `undefined` value": never; }' + brandedStringAliasIndexOfTrueOrUndefined, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + [x: BrandedString]: true; + }>(), + ); + }); + + it("as optional exact property type > varies by exactOptionalPropertyTypes setting", () => { + // See sibling test files + }); + + it("under an optional property", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error not assignable to `{ "error required property may not allow `undefined` value": never; }` + objectWithOptionalUndefinedEnclosingRequiredUndefined, + { opt: {} }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + opt?: { + requiredUndefined?: number; + }; + }>(), + ); + }); + }); + + // Since `unknown` allows `undefined`, any uses of `unknown` must be optional. + describe("object with required `unknown`", () => { + it("as exact property type", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error not assignable to type '{ "error required property may not allow `unknown` value": never; }' + objectWithUnknown, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ unknown?: JsonTypeWith }>()); + }); + it("as exact property type adjacent to recursion", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error not assignable to type '{ "error required property may not allow `unknown` value": never; }' + objectWithUnknownAdjacentToOptionalRecursion, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + outer: { + recursive?: ObjectWithOptionalRecursion; + }; + unknown?: JsonTypeWith; + }>(), + ); + }); + it("as exact property type in recursion", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error not assignable to type '{ "error required property may not allow `unknown` value": never; }' + objectWithUnknownInOptionalRecursion, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + unknown?: JsonTypeWith; + recurse?: OpaqueJsonDeserialized; + }>(), + ); + }); + }); + + describe("of class instance", () => { + it("with private data", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error SerializationErrorPerNonPublicProperties + classInstanceWithPrivateData, + { + public: "public", + // secret is also not allowed but is present + secret: 0, + }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ public: string }>()); + }); + it("with private method", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error SerializationErrorPerNonPublicProperties + classInstanceWithPrivateMethod, + { + public: "public", + }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ public: string }>()); + }); + it("with private getter", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error SerializationErrorPerNonPublicProperties + classInstanceWithPrivateGetter, + { + public: "public", + }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ public: string }>()); + }); + it("with private setter", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error SerializationErrorPerNonPublicProperties + classInstanceWithPrivateSetter, + { + public: "public", + }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ public: string }>()); + }); + it("with public method", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error function not assignable to never + classInstanceWithPublicMethod, + { public: "public" }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ public: string }>()); + // @ts-expect-error getSecret is missing, but required + assertIdenticalTypes(out, classInstanceWithPublicMethod); + assert.ok( + classInstanceWithPublicMethod instanceof ClassWithPublicMethod, + "classInstanceWithPublicMethod is an instance of ClassWithPublicMethod", + ); + assert.ok( + !(out instanceof ClassWithPublicMethod), + "out is not an instance of ClassWithPublicMethod", + ); + }); + it("with private data in optional recursion", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error SerializationErrorPerNonPublicProperties + objectWithClassWithPrivateDataInOptionalRecursion, + { + class: { + public: "public", + // secret is also not allowed but is present + secret: 0, + }, + recurse: { + class: { + public: "public", + // secret is also not allowed but is present + secret: 0, + }, + }, + }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ + class: { + public: string; + }; + recurse?: OpaqueJsonDeserialized< + typeof objectWithClassWithPrivateDataInOptionalRecursion + >; + }>(), + ); + }); + }); + }); + + describe("opaque Json types requiring extra allowed types", () => { + it("opaque serializable object with `bigint`", () => { + assert.throws( + () => + JsonStringify( + // @ts-expect-error `bigint` is not supported and `AllowExactly` parameters are incompatible + opaqueSerializableObjectRequiringBigintSupport, + ), + new TypeError("Do not know how to serialize a BigInt"), + ); + }); + it("opaque deserialized object with `bigint`", () => { + assert.throws( + () => + JsonStringify( + // @ts-expect-error `bigint` is not supported and `AllowExactly` parameters are incompatible + opaqueDeserializedObjectRequiringBigintSupport, + ), + new TypeError("Do not know how to serialize a BigInt"), + ); + }); + it("opaque serializable and deserialized object with `bigint`", () => { + assert.throws( + () => + JsonStringify( + // @ts-expect-error `bigint` is not supported and `AllowExactly` parameters are incompatible + opaqueSerializableAndDeserializedObjectRequiringBigintSupport, + ), + new TypeError("Do not know how to serialize a BigInt"), + ); + }); + + it("opaque serializable object with number array expecting `bigint` support", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error The types of 'JsonSerializable.Options.AllowExtensionOf' are incompatible between these types. Type 'bigint' is not assignable to type 'never'. + opaqueSerializableObjectExpectingBigintSupport, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes( + out, + createInstanceOf< + OpaqueJsonDeserialized<{ + readonlyArrayOfNumbers: readonly number[]; + }> + >(), + ); + }); + it("opaque deserialized object with number array expecting `bigint` support", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error The types of 'JsonSerializable.Options.AllowExtensionOf' are incompatible between these types. Type 'bigint' is not assignable to type 'never'. + opaqueDeserializedObjectExpectingBigintSupport, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes( + out, + createInstanceOf< + OpaqueJsonDeserialized<{ + readonlyArrayOfNumbers: readonly number[]; + }> + >(), + ); + }); + it("opaque serializable and deserialized object with number array expecting `bigint` support", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error The types of 'JsonSerializable.Options.AllowExtensionOf' are incompatible between these types. Type 'bigint' is not assignable to type 'never'. + opaqueSerializableAndDeserializedObjectExpectingBigintSupport, + ); + assertIdenticalTypes( + stringified, + createInstanceOf< + JsonString + >(), + ); + assertIdenticalTypes( + out, + createInstanceOf< + OpaqueJsonDeserialized<{ readonlyArrayOfNumbers: readonly number[] }> + >(), + ); + }); + }); + + describe("common class instances", () => { + it("Map", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error methods not assignable to never + mapOfStringsToNumbers, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ readonly size: number }>()); + // @ts-expect-error methods are missing, but required + out satisfies typeof readonlyMapOfStringsToNumbers; + // @ts-expect-error methods are missing, but required + assertIdenticalTypes(out, readonlyMapOfStringsToNumbers); + assert.ok( + mapOfStringsToNumbers instanceof Map, + "mapOfStringsToNumbers is an instance of Map", + ); + assert.ok(!(out instanceof Map), "out is not an instance of Map"); + }); + it("ReadonlyMap", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error methods not assignable to never + readonlyMapOfStringsToNumbers, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ readonly size: number }>()); + // @ts-expect-error methods are missing, but required + out satisfies typeof readonlyMapOfStringsToNumbers; + // @ts-expect-error methods are missing, but required + assertIdenticalTypes(out, readonlyMapOfStringsToNumbers); + assert.ok( + mapOfStringsToNumbers instanceof Map, + "mapOfStringsToNumbers is an instance of Map", + ); + assert.ok(!(out instanceof Map), "out is not an instance of Map"); + }); + it("Set", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error methods not assignable to never + setOfNumbers, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ readonly size: number }>()); + // @ts-expect-error methods are missing, but required + out satisfies typeof setOfNumbers; + // @ts-expect-error methods are missing, but required + assertIdenticalTypes(out, setOfNumbers); + assert.ok( + setOfNumbers instanceof Set, + "mapOfStringsToNumbers is an instance of Set", + ); + assert.ok(!(out instanceof Set), "out is not an instance of Set"); + }); + it("ReadonlySet", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error methods not assignable to never + readonlySetOfNumbers, + {}, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ readonly size: number }>()); + // @ts-expect-error methods are missing, but required + out satisfies typeof setOfNumbers; + // @ts-expect-error methods are missing, but required + assertIdenticalTypes(out, setOfNumbers); + assert.ok( + setOfNumbers instanceof Set, + "mapOfStringsToNumbers is an instance of Set", + ); + assert.ok(!(out instanceof Set), "out is not an instance of Set"); + }); + }); + + describe("Fluid types", () => { + it("`IFluidHandle`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error SerializationErrorPerNonPublicProperties + fluidHandleToNumber, + { isAttached: false }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes(out, createInstanceOf<{ readonly isAttached: boolean }>()); + }); + it("object with `IFluidHandle`", () => { + const { stringified, out } = stringifyThenParse( + // @ts-expect-error SerializationErrorPerNonPublicProperties + objectWithFluidHandle, + { handle: { isAttached: false } }, + ); + assertIdenticalTypes( + stringified, + createInstanceOf>(), + ); + assertIdenticalTypes( + out, + createInstanceOf<{ handle: { readonly isAttached: boolean } }>(), + ); + }); + }); + }); + }); + + describe("special cases", () => { + it("explicit `any` generic still limits allowed types", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stringified = JsonStringify( + // @ts-expect-error `any` is not an open door (expects `JsonTypeWith | OpaqueJsonSerializable`) + undefined, + ); + assert.strictEqual(stringified, undefined); + }); + + describe("`number` edge cases", () => { + describe("supported", () => { + it("MIN_SAFE_INTEGER", () => { + const { stringified, out } = stringifyThenParse(Number.MIN_SAFE_INTEGER); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, createInstanceOf()); + }); + it("MAX_SAFE_INTEGER", () => { + const { stringified, out } = stringifyThenParse(Number.MAX_SAFE_INTEGER); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, createInstanceOf()); + }); + it("MIN_VALUE", () => { + const { stringified, out } = stringifyThenParse(Number.MIN_VALUE); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, createInstanceOf()); + }); + it("MAX_VALUE", () => { + const { stringified, out } = stringifyThenParse(Number.MAX_VALUE); + assertIdenticalTypes(stringified, createInstanceOf>()); + assertIdenticalTypes(out, createInstanceOf()); + }); + }); + describe("resulting in `null`", () => { + it("NaN", () => { + const { stringified, out } = stringifyThenParse(Number.NaN, null); + assertIdenticalTypes(stringified, createInstanceOf>()); + // However, real result is `null` + assertIdenticalTypes(out, createInstanceOf()); + }); + + it("+Infinity", () => { + const { stringified, out } = stringifyThenParse(Number.POSITIVE_INFINITY, null); + assertIdenticalTypes(stringified, createInstanceOf>()); + // However, real result is `null` + assertIdenticalTypes(out, createInstanceOf()); + }); + it("-Infinity", () => { + const { stringified, out } = stringifyThenParse(Number.NEGATIVE_INFINITY, null); + assertIdenticalTypes(stringified, createInstanceOf>()); + // However, real result is `null` + assertIdenticalTypes(out, createInstanceOf()); + }); + }); + }); + }); +}); + +describe("JsonParse", () => { + it("parses `JsonString | JsonString` to `JsonDeserialized`", () => { + // Setup + const jsonString = JsonStringify({ "a": 6 }) as + | JsonString<{ a: number }> + | JsonString<{ b: string } | { c: boolean }>; + + // Act + const parsed = JsonParse(jsonString); + + // Verify + assertIdenticalTypes( + parsed, + createInstanceOf<{ a: number } | { b: string } | { c: boolean }>(), + ); + assert.deepStrictEqual(parsed, { a: 6 }); + }); +}); diff --git a/packages/common/core-interfaces/src/test/testUtils.ts b/packages/common/core-interfaces/src/test/testUtils.ts index d04ab62c1dfa..6ca581a7febb 100644 --- a/packages/common/core-interfaces/src/test/testUtils.ts +++ b/packages/common/core-interfaces/src/test/testUtils.ts @@ -30,6 +30,13 @@ export function assertIdenticalTypes( return undefined as InternalUtilityTypes.IfSameType; } +/** + * Use to verify that a parameter is accepted as a specific type. + */ +export function parameterAcceptedAs(_: T): void { + // Do nothing. Used to verify type compatibility. +} + /** * Creates a non-viable (`undefined`) instance of type T to be used for type checking. */ diff --git a/packages/common/core-interfaces/src/test/testValues.ts b/packages/common/core-interfaces/src/test/testValues.ts index 10a338c6d6ec..af2e7be6c0b7 100644 --- a/packages/common/core-interfaces/src/test/testValues.ts +++ b/packages/common/core-interfaces/src/test/testValues.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { assertIdenticalTypes } from "./testUtils.js"; +import { assertIdenticalTypes, replaceBigInt } from "./testUtils.js"; import type { ErasedType, @@ -13,6 +13,7 @@ import type { import { fluidHandleSymbol } from "@fluidframework/core-interfaces"; import type { BrandedType, + JsonString, ReadonlyNonNullJsonObjectWith, } from "@fluidframework/core-interfaces/internal"; import type { @@ -797,6 +798,18 @@ export const opaqueSerializableInRecursiveStructure: DirectoryOfValues< item2: { items: { subItem1: {} } }, }, }; +/** + * This type represents the deserialized form of {@link opaqueSerializableInRecursiveStructure} + */ +export interface DeserializedOpaqueSerializableInRecursiveStructure { + items: { + [x: string | number]: + | OpaqueJsonDeserialized>> + | { + value?: OpaqueJsonDeserialized; + }; + }; +} export const opaqueDeserializedInRecursiveStructure: DirectoryOfValues< OpaqueJsonDeserialized @@ -815,6 +828,20 @@ export const opaqueSerializableAndDeserializedInRecursiveStructure: DirectoryOfV item2: { items: { subItem1: {} } }, }, }; +/** + * This type represents the deserialized form of {@link opaqueSerializableAndDeserializedInRecursiveStructure} + */ +export interface DeserializedOpaqueSerializableAndDeserializedInRecursiveStructure { + items: { + [x: string | number]: + | OpaqueJsonDeserialized< + DirectoryOfValues & OpaqueJsonDeserialized> + > + | { + value?: OpaqueJsonDeserialized; + }; + }; +} export const opaqueSerializableObjectRequiringBigintSupport = objectWithBigint as unknown as OpaqueJsonSerializable; @@ -842,6 +869,21 @@ export const opaqueSerializableAndDeserializedObjectExpectingBigintSupport = > & OpaqueJsonDeserialized; +export const jsonStringOfString = JSON.stringify(string) as JsonString; +export const jsonStringOfObjectWithArrayOfNumbers = JSON.stringify( + objectWithArrayOfNumbers, +) as JsonString; +export const jsonStringOfStringRecordOfNumbers = JSON.stringify( + stringRecordOfNumbers, +) as JsonString; +export const jsonStringOfStringRecordOfNumberOrUndefined = JSON.stringify( + stringRecordOfNumberOrUndefined, +) as JsonString; +export const jsonStringOfBigInt = JSON.stringify(bigint, replaceBigInt) as JsonString; +export const jsonStringOfUnknown = JSON.stringify({ + unknown: "you don't know me", +}) as JsonString; + // #endregion /* eslint-enable @typescript-eslint/consistent-type-definitions */