Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/common/core-interfaces/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/common/core-interfaces/src/cjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"./internal": {
"types": "./internal.d.ts",
"default": "./index.js"
"default": "./internal.js"
},
"./internal/exposedUtilityTypes": "./exposedUtilityTypes.js"
}
Expand Down
5 changes: 2 additions & 3 deletions packages/common/core-interfaces/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see these two are just redefinitions of native functions but feels like they should still go to core-utils to keep this package types-only?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package cannot be type-only because it has enums.
core-utils does not depend on core-interfaces. If relocated, they could go to client-utils.
Since it can't be type-only and relocation would involve adding test exports to core-interfaces for all of the testing, I am inclined to say this is okay. But I will defer to the prevailing wisdom.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, client-utils is the one I was thinking of.

I can't think of strong reasons to oppose exporting them from here, but don't feel super confident being sole approver. I'd suggest getting more eyes on this. @anthony-murphy comes to mind as someone who might have comments.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really grasping the implications of this change, if there are concerns please bottom-out on those, despite my approval on the PR as a whole

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is mostly a policy / positioning for this package to be type-only and not have any runtime contribution. But that is not the case with these packages anyhow because enums or enum-like types. I do not want us to start down a slippery slope. I am convinced the Json* type filters and JsonString belong here. I think it is okay to has JsonStringify and JsonParse colocated when there are just retypings of existing functions.


export type { JsonTypeToOpaqueJson, OpaqueJsonToJsonType } from "./jsonUtils.js";

Expand Down
85 changes: 85 additions & 0 deletions packages/common/core-interfaces/src/jsonString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*!
* 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 { JsonDeserialized } from "./jsonDeserialized.js";
import type { JsonSerializable } from "./jsonSerializable.js";

/**
* Brand for JSON that has been stringified.
*
* Usage: Intersect with another type to apply branding.
*
* @sealed
*/
declare class JsonStringBrand<T> extends BrandedType<JsonString<unknown>> {
public toString(): string;
protected readonly EncodedValue: T;
private constructor();
}

/**
* 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<T>` when value of type `T` has been stringified.
*
* - use a form of {@link JsonDeserialized} for safety when parsing.
*
* @sealed
* @internal
*/
export type JsonString<T> = string & JsonStringBrand<T>;

/**
* Options for {@link JsonStringify}.
*
* @internal
*/
export interface JsonStringifyOptions {
/**
* When set, inaccessible (protected and private) members throughout type T are
* ignored as if not present. Otherwise, inaccessible members are considered
* an error (type checking will mention `SerializationErrorPerNonPublicProperties`).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* When set, inaccessible (protected and private) members throughout type T are
* ignored as if not present. Otherwise, inaccessible members are considered
* an error (type checking will mention `SerializationErrorPerNonPublicProperties`).
* When set, inaccessible (protected and private) members throughout type T are
* ignored when computing derived types.
* This has no impact on how the such fields are handled at runtime:
* For this setting to match runtime behavior all public fields must be enumerable own properties,
* and all inaccessible ones (if any) must be either inherited, symbol keyed or non-enumerable
* When this option is not set, inaccessible members are considered
* an type error (type checking will mention `SerializationErrorPerNonPublicProperties`).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setting shouldn't have anything to do with public properties enumerable or not. But that comment is appropriate for JsonStringify assuming that is requirement of JSON.stringify.

*
* @remarks
* The default is that `IgnoreInaccessibleMembers` property is not specified,
* which means that inaccessible members are considered an error.
*/
IgnoreInaccessibleMembers?: "ignore-inaccessible-members";
}

/**
* Performs basic JSON serialization using `JSON.stringify` and brands the result as {@link JsonString}`<T>`.
*
* @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,
// eslint-disable-next-line @typescript-eslint/ban-types -- `Record<string, never>` is not sufficient replacement for empty object.
Options extends JsonStringifyOptions = {},
>(
value: JsonSerializable<T, Options>,
) => JsonString<T>;
Comment on lines 101 to 110
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The type assertion as is used to cast JSON.stringify to the branded return type. While this works, consider adding a comment explaining why this casting is safe and necessary for the branded type system.

Copilot uses AI. Check for mistakes.

/**
* Performs basic JSON parsing using `JSON.parse` given a {@link JsonString}`<T>` (`string`).
*
* @remarks
* Return type is filtered through {@link JsonDeserialized}`<T>` for best accuracy.
*
* @internal
*/
export const JsonParse: <T>(text: JsonString<T>) => JsonDeserialized<T> = JSON.parse;
69 changes: 44 additions & 25 deletions packages/common/core-interfaces/src/test/jsonDeserialized.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import type {
BrandedKey,
BrandedString,
DecodedValueDirectoryOrRequiredState,
DeserializedOpaqueSerializableInRecursiveStructure,
DeserializedOpaqueSerializableAndDeserializedInRecursiveStructure,
DirectoryOfValues,
ObjectWithOptionalRecursion,
} from "./testValues.js";
Expand Down Expand Up @@ -211,6 +213,12 @@ import {
opaqueSerializableObjectExpectingBigintSupport,
opaqueDeserializedObjectExpectingBigintSupport,
opaqueSerializableAndDeserializedObjectExpectingBigintSupport,
jsonStringOfString,
jsonStringOfObjectWithArrayOfNumbers,
jsonStringOfStringRecordOfNumbers,
jsonStringOfStringRecordOfNumberOrUndefined,
jsonStringOfBigInt,
jsonStringOfUnknown,
} from "./testValues.js";

import type { IFluidHandle } from "@fluidframework/core-interfaces";
Expand All @@ -220,7 +228,6 @@ import type {
JsonTypeWith,
NonNullJsonObjectWith,
OpaqueJsonDeserialized,
OpaqueJsonSerializable,
} from "@fluidframework/core-interfaces/internal/exposedUtilityTypes";

/**
Expand Down Expand Up @@ -368,6 +375,36 @@ describe("JsonDeserialized", () => {
assertIdenticalTypes(resultRead, brandedString);
assertNever<AnyLocations<typeof resultRead>>();
});
it("`JsonString<string>`", () => {
const resultRead = passThru(jsonStringOfString);
assertIdenticalTypes(resultRead, jsonStringOfString);
assertNever<AnyLocations<typeof resultRead>>();
});
it("`JsonString<{ arrayOfNumbers: number[] }>`", () => {
const resultRead = passThru(jsonStringOfObjectWithArrayOfNumbers);
assertIdenticalTypes(resultRead, jsonStringOfObjectWithArrayOfNumbers);
assertNever<AnyLocations<typeof resultRead>>();
});
it("`JsonString<Record<string, number>>`", () => {
const resultRead = passThru(jsonStringOfStringRecordOfNumbers);
assertIdenticalTypes(resultRead, jsonStringOfStringRecordOfNumbers);
assertNever<AnyLocations<typeof resultRead>>();
});
it("`JsonString<Record<string, number | undefined>>`", () => {
const resultRead = passThru(jsonStringOfStringRecordOfNumberOrUndefined);
assertIdenticalTypes(resultRead, jsonStringOfStringRecordOfNumberOrUndefined);
assertNever<AnyLocations<typeof resultRead>>();
});
it("JsonString<bigint>", () => {
const resultRead = passThru(jsonStringOfBigInt);
assertIdenticalTypes(resultRead, jsonStringOfBigInt);
assertNever<AnyLocations<typeof resultRead>>();
});
it("JsonString<unknown>", () => {
const resultRead = passThru(jsonStringOfUnknown);
assertIdenticalTypes(resultRead, jsonStringOfUnknown);
assertNever<AnyLocations<typeof resultRead>>();
});
});

describe("unions with unsupported primitive types preserve supported types", () => {
Expand Down Expand Up @@ -516,11 +553,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<AnyLocations<typeof resultRead>>();
});
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<AnyLocations<typeof resultRead>>();
});
Expand Down Expand Up @@ -1677,15 +1718,6 @@ describe("JsonDeserialized", () => {
});
it("object with OpaqueJsonSerializable<unknown> in recursion is unrolled one time with OpaqueJsonDeserialized", () => {
const resultRead = passThru(opaqueSerializableInRecursiveStructure);
interface DeserializedOpaqueSerializableInRecursiveStructure {
items: {
[x: string | number]:
| OpaqueJsonDeserialized<DirectoryOfValues<OpaqueJsonSerializable<unknown>>>
| {
value?: OpaqueJsonDeserialized<unknown>;
};
};
}
assertIdenticalTypes(
resultRead,
createInstanceOf<DeserializedOpaqueSerializableInRecursiveStructure>(),
Expand All @@ -1708,22 +1740,9 @@ describe("JsonDeserialized", () => {
// It might be better to preserve the intersection and return original type.
it("object with OpaqueJsonSerializable<unknown> & OpaqueJsonDeserialized<unknown> in recursion is unrolled one time with OpaqueJsonDeserialized", () => {
const resultRead = passThru(opaqueSerializableAndDeserializedInRecursiveStructure);
interface DeserializedOpaqueSerializableInRecursiveStructure {
items: {
[x: string | number]:
| OpaqueJsonDeserialized<
DirectoryOfValues<
OpaqueJsonSerializable<unknown> & OpaqueJsonDeserialized<unknown>
>
>
| {
value?: OpaqueJsonDeserialized<unknown>;
};
};
}
assertIdenticalTypes(
resultRead,
createInstanceOf<DeserializedOpaqueSerializableInRecursiveStructure>(),
createInstanceOf<DeserializedOpaqueSerializableAndDeserializedInRecursiveStructure>(),
);
assertNever<AnyLocations<typeof resultRead>>();
const transparentResult = revealOpaqueJson(resultRead);
Expand All @@ -1732,7 +1751,7 @@ describe("JsonDeserialized", () => {
createInstanceOf<{
items: {
[x: string | number]:
| DeserializedOpaqueSerializableInRecursiveStructure
| DeserializedOpaqueSerializableAndDeserializedInRecursiveStructure
| {
value?: JsonTypeWith<never>;
};
Expand Down
62 changes: 54 additions & 8 deletions packages/common/core-interfaces/src/test/jsonSerializable.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ import {
opaqueSerializableObjectExpectingBigintSupport,
opaqueDeserializedObjectExpectingBigintSupport,
opaqueSerializableAndDeserializedObjectExpectingBigintSupport,
jsonStringOfString,
jsonStringOfObjectWithArrayOfNumbers,
jsonStringOfStringRecordOfNumbers,
jsonStringOfStringRecordOfNumberOrUndefined,
jsonStringOfBigInt,
jsonStringOfUnknown,
} from "./testValues.js";

import type { IFluidHandle } from "@fluidframework/core-interfaces";
Expand Down Expand Up @@ -441,6 +447,36 @@ describe("JsonSerializable", () => {
assertIdenticalTypes(filteredIn, brandedString);
assertIdenticalTypes(filteredIn, out);
});
it("`JsonString<string>`", () => {
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<Record<string, number>>`", () => {
const { filteredIn, out } = passThru(jsonStringOfStringRecordOfNumbers);
assertIdenticalTypes(filteredIn, jsonStringOfStringRecordOfNumbers);
assertIdenticalTypes(filteredIn, out);
});
it("`JsonString<Record<string, number | undefined>>`", () => {
const { filteredIn, out } = passThru(jsonStringOfStringRecordOfNumberOrUndefined);
assertIdenticalTypes(filteredIn, jsonStringOfStringRecordOfNumberOrUndefined);
assertIdenticalTypes(filteredIn, out);
});
it("JsonString<bigint>", () => {
const { filteredIn, out } = passThru(jsonStringOfBigInt);
assertIdenticalTypes(filteredIn, jsonStringOfBigInt);
assertIdenticalTypes(filteredIn, out);
});
it("JsonString<unknown>", () => {
const { filteredIn, out } = passThru(jsonStringOfUnknown);
assertIdenticalTypes(filteredIn, jsonStringOfUnknown);
assertIdenticalTypes(filteredIn, out);
});
});

describe("supported literal types", () => {
Expand Down Expand Up @@ -1566,8 +1602,10 @@ describe("JsonSerializable", () => {
});

it("`string` indexed record of `unknown`", () => {
// @ts-expect-error not assignable to parameter of type '{ [x: string]: JsonTypeWith<never> | OpaqueJsonSerializable<unknown>; }'.
const { filteredIn } = passThru(stringRecordOfUnknown);
const { filteredIn } = passThru(
// @ts-expect-error not assignable to parameter of type '{ [x: string]: JsonTypeWith<never> | OpaqueJsonSerializable<unknown>; }'.
stringRecordOfUnknown,
);
assertIdenticalTypes(
filteredIn,
createInstanceOf<{
Expand All @@ -1576,8 +1614,10 @@ describe("JsonSerializable", () => {
);
});
it("`Partial<>` `string` indexed record of `unknown`", () => {
// @ts-expect-error not assignable to parameter of type '{ [x: string]: JsonTypeWith<never> | OpaqueJsonSerializable<unknown>; }'.
const { filteredIn } = passThru(partialStringRecordOfUnknown);
const { filteredIn } = passThru(
// @ts-expect-error not assignable to parameter of type '{ [x: string]: JsonTypeWith<never> | OpaqueJsonSerializable<unknown>; }'.
partialStringRecordOfUnknown,
);
assertIdenticalTypes(
filteredIn,
createInstanceOf<{
Expand All @@ -1593,8 +1633,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<{
Expand All @@ -1611,8 +1654,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<{
Expand Down
Loading
Loading