Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(client): JsonSerializable and JsonDeserialized #21725

Open
wants to merge 44 commits into
base: main
Choose a base branch
from

Conversation

jason-ha
Copy link
Contributor

@jason-ha jason-ha commented Jul 1, 2024

Pair of type filters for JSON based serialization.

JsonSerializable<T> produces type representing limitations of serializing T. Incompatible elements are transformed to never or SerializationError* types that orignal T is not assignable to.

JsonDeserialized<T> produces type representing result of T being serialized and then deserialized.

JsonSerializable should eventually replace @fluidframework/datastore-definitions's Jsonable. That cannot done be currently as it would be a compile-time breaking change.

AB#6887

+ test objects with certain properties

Note some new Deserialized tests are failing
+ cleanup error for required property with undefined
for deserialization tests in place of class instances
by using homomorphic mapping with `as`
and misc formatting improvements
and detach expect type from input type T to avoid influencing inference of T
reorganize for documentation and add/correct some docs
@msfluid-bot
Copy link
Collaborator

msfluid-bot commented Jul 1, 2024

@fluid-example/bundle-size-tests: +245 Bytes
Metric NameBaseline SizeCompare SizeSize Diff
aqueduct.js 457.26 KB 457.3 KB +35 Bytes
azureClient.js 555.05 KB 555.1 KB +49 Bytes
connectionState.js 680 Bytes 680 Bytes No change
containerRuntime.js 258.33 KB 258.35 KB +14 Bytes
fluidFramework.js 392.16 KB 392.17 KB +14 Bytes
loader.js 134.1 KB 134.11 KB +14 Bytes
map.js 42.17 KB 42.17 KB +7 Bytes
matrix.js 145.56 KB 145.57 KB +7 Bytes
odspClient.js 522.82 KB 522.86 KB +49 Bytes
odspDriver.js 97.17 KB 97.19 KB +21 Bytes
odspPrefetchSnapshot.js 42.27 KB 42.29 KB +14 Bytes
sharedString.js 162.65 KB 162.66 KB +7 Bytes
sharedTree.js 382.62 KB 382.63 KB +7 Bytes
Total Size 3.27 MB 3.27 MB +245 Bytes

Baseline commit: 6fd320c

Generated by 🚫 dangerJS against 5e3b470

* @system
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace InternalUtilityTypes {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Consider renaming to 'InternalTypes' for consistency with tree DDS?

import * as InternalTypes from "./internalTypes.js";
export {
	/**
	 * Contains types used by the API, but which serve mechanical purposes and do not represent semantic concepts.
	 * They are used internally to implement API aspects, but are not intended for use by external consumers.
	 */
	InternalTypes,
};
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am expecting that we are going to end up with multiple namespaces in some packages for multiple internal things and the only thing that needs to be consistent (my opinion) is Internal prefix on the namespace. None of these should be imported from outside of the package (darn api-extractor); so, I think name that makes sense for internal description is good AND we should be able to arbitrarily change these names without it being considered a breaking change for customers.

BTW tree is also investigating use of explicit namespaces similar to what I used here. I tried the tree pattern show above, but api-extractor is unhappy about {@link } references when you rename like that.

renames and a rework for safe use
jason-ha added a commit to jason-ha/FluidFramework that referenced this pull request Jul 3, 2024
@jason-ha
Copy link
Contributor Author

@CraigMacomber and/or @anthony-murphy, would you be able to find time to review these changes? (Per Daniel's suggestion as he has run out of time before vacation.)


/**
* Collection of utility types that are not intended to be used/imported
* directly outside of this package.
Copy link
Contributor

Choose a reason for hiding this comment

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

Might be worth including in this comment they may not follow semver, since I assume thats the intention (both to communicate it to devs working on/using/reviewing these types but also to customers)

Comment on lines +425 to +434
/**
* Sentinel type for use when marking points of recursion (in a recursive type).
* Type is expected to be unique, though no lengths is taken to ensure that.
*
* @beta
* @system
*/
export interface RecursionMarker {
"recursion here": "recursion here";
}
Copy link
Contributor

Choose a reason for hiding this comment

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

It's possible for this to collide with regular data. Picking something faster to compare and collision resistant seems like it would be a good idea. unique symbols seem ideal for that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am open to suggestions. I had mixed luck trying to use symbols in here. So, I didn't over think it.

Comment on lines +14 to +19
/**
* A type that is managed by custom deserialization logic (beyond JSON.parse).
*
* The default value is `never`.
*/
Replaced: unknown;
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a bit confusing. This seems like a runtime set of options (based on it not being a generic type to take in the parameter), but never is a compile time only thing, and this value isn't optional so how does it have a default anyway?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Breaking type out like this is the guidance for our docs. It is only to be used with JsonDeserialized type param and that is where the default exists. Looked more reasonable on the doc site.

*
* @beta
*/
export type JsonDeserialized<
Copy link
Contributor

Choose a reason for hiding this comment

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

Given this is beta and not internal, I assume there is some public place we intend to use this API? Jsonable is currently @legacy @alpha, so this is more public than needed to replace that.

MatrixItem and ISharedSummaryBlock seem to be the only exported APIs using Jsonable. Is this intended for them or maybe some future use-case?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Jsonable is @legacy so this needs to be @public to be able to replace it. (Actually, it is JsonSerializable that will replace Jsonable. There is no deserialized counterpart in the existing stuff.)
Already noted in the description. :)

Comment on lines +32 to +36
* Deserialized JSON never contains `undefined`, `symbol`, or function values.
* Object properties with values of those types are absent. So properties
* become optional (when there are other supported types in union) or are
* removed (when nothing else in union is supported).
* In an array, such values are replaced with `null`.
Copy link
Contributor

Choose a reason for hiding this comment

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

When using tools like git which are line based, personally I prefer more semantic line breaks over arbitrary ones mainly
Starting sentences on new lines. Also, I don't think starting a sentence with a coordinating conjunction like "so" like that is correct. either than period should become a comma, or we can use a colon here (which I think makes the most sense):

Suggested change
* Deserialized JSON never contains `undefined`, `symbol`, or function values.
* Object properties with values of those types are absent. So properties
* become optional (when there are other supported types in union) or are
* removed (when nothing else in union is supported).
* In an array, such values are replaced with `null`.
* Deserialized JSON never contains `undefined`, `symbol`, or function values.
* Object properties which had those values before encoding are omitted by JSON.stringify:
* Therefor in this type properties become optional (when there are other supported types in union)
* or are removed (when nothing else in union is supported).
* In an array, such values are replaced with `null`.
Comment on lines +38 to +39
* `bigint` valued properties are simply removed as serialization attempts
* will throw.
Copy link
Contributor

Choose a reason for hiding this comment

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

There are a lot of things this doc comment does not cover, despite it seeming to make an attempt to be complete, so I'll mention them here:

  • Its perfectly possible to losslessly encode bigints into and from JSON. JSON allows arbitrary numeric precision. This doc comment makes no mention of JSON.strinify, so applying its limitation here seems arbitrary. There even is https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/rawJSON which makes it possible to to losslesslyencode bigints using JSON.stringify ( https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON#using_json_numbers )
  • No mention of what subset of properties are considered at runtime. Typically, this is enumerable own properties. I thing the section on setters/getters is simply wrong. I'm not sure what you mean by "backed by a function": setters and getters always use functions. Assuming we are talking about JSON.strinify and similar functions what actually matters is if the property is an enumerable own property. Setter don't matter and getters are not special really as they are included when enumerable own. Since TypeScript does not capture ownness or enumerableness of properties in its type system, and most serializers including our stringify and toJson rely on them think this needs to be called out explicitly.
  • No mention of the JSON limits on strings or numbers. Strings with unmatched utf16 surrogate pairs will not round trip, nor will NaN or infinities.
  • You mention recursive types, but not recursive values

Generally the existing doc comment on Jsonable seems to cover a lot of details which are missing here (ex: the special number cases, assumptions about enumerability of properties, issues with ArrayLike, recursive object issues. If this is intending to replace that API, it seems like its docs should be at least as complete.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe this should link JsonSerializable's docs to cover all these cases, and document (at least in a private remarks) why we need both and how they differ?

: /* non-object => T as is */ T;

/**
* Test for non-public properties (class instance type)
Copy link
Contributor

Choose a reason for hiding this comment

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

I played with this a bit. Its surprisingly robust. Neat utility.

Suggested change
* Test for non-public properties (class instance type)
* Test for non-public properties (which can only exist on class instance types).
* Returns true if `T` deeply may contain (via non function members) a private or protected field.
* @remarks
* This does not distribute over unions: `ReplaceRecursionWith<A> | ReplaceRecursionWith<B>` will produce Boolean when `ReplaceRecursionWith<A | B>` produces true;

I did however find at least one edge case it doesn't handle: classes which extend Function return false even if they have private members. I didn't try out recursive cases.

Might be worth calling out that this has no impact on properties which are present on objects, but not included in the type. That may be obvious, but with the ways this is used, that limitation is likely to impact people. If I implement an interface with a class, or up cast a more specific type to a more general interface before it goes into the JsonSeralizable logic, it all fails to detect the omitted members despite them still impacting runtime. This limitation seems more likely to cause issues most of the things it does detect, which makes me wonder if this whole mechanism is really worth all the complexity as there is no way to fix that issue.

*
* @beta
*/
export type JsonDeserialized<
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 reasonable (but in my opinion not particularly well motivated given the complexity and limitations) use-cases for JsonSserialized, but JsonDeserialized doesn't have a usecase I can find other than casting unvalidated deserialized data to a type it might not actually be compatible with. While I'm ok with customers doing such casts when they think its safe, them doing so does not require this type since they can just cast to their actual type.

Having us use this inside a library seems like its encapsulating a possibly incorrect cast which there is no possible way to validate into our framework, which seems like a bad idea, especially if we expose it publicly. We might have some internal use-cases for this, but I don't think we should use it in a public API, so maybe this specific type should be marked internal instead of beta?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In the world of signals, it is all very badly typed and there is little validation. It is kind of impressive but also makes me feel confident when I add a new use that there aren't weird things making use of this for type improvements.
There are definitely risk-benefit tradeoffs being made.
As discussed in Teams chat, I think there is space to add some validation option or at least note where things are not otherwise validated.

*
* @beta
*/
export type JsonSerializable<
Copy link
Contributor

Choose a reason for hiding this comment

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

Given we don't have actual users for this yet, is there a reason for this to skip the alpha stage straight to beta? Seems like this could either start as internal or alpha, and get promoted to beta when the libraries that depend on it need to be marked beta?

I'm generally in favor of not stabilizing things any more than we have a reason to maximize our flexibility and API readability (unused things in API docs make finding things you need harder).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I believe @vladsud has a use and I want it for experimental DIS that is very definitely getting promoted. And I started this a while back. So when I made @alpha available I didn't try to demote it. I certainly can.

* result can be more informative than just the type name.
*
* @beta
* @system
Copy link
Contributor

Choose a reason for hiding this comment

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

do we document @system user facing anywhere yet?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need to add support to the website. @Josmithr, want to give it a go? :) I did add notes to our wiki: https://github.com/microsoft/FluidFramework/wiki/TSDoc-Guidelines#system

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
base: main PRs targeted against main branch dependencies Pull requests that update a dependency file public api change Changes to a public API
4 participants