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

Add struct utils for validating JSON objects with optional values #136

Merged
merged 14 commits into from
Oct 19, 2023

Conversation

Mrtenz
Copy link
Member

@Mrtenz Mrtenz commented Aug 31, 2023

This adds two new structs:

  • object: Similar to the object struct from Superstruct, but infers optional values as key?: value instead of key?: value | undefined.
  • exactOptional: Similar to the optional struct from Superstruct, but does not allow undefined values.

This lets us use proper runtime validation for these objects, and also type these objects in a way that they don't allow undefined values, without the need to modify the inferred type.

I've updated all the existing structs, with the exception of PendingJsonRpcResponseStruct, since it seems like undefined is valid for this type, at least according to the tests.

@legobeat

This comment was marked as resolved.

legobeat
legobeat previously approved these changes Aug 31, 2023
@legobeat
Copy link
Contributor

legobeat commented Aug 31, 2023

typings diff vs current main:

diff -x '*.map' -wr -U1 dist-a00dfc9d/types/json.d.ts dist-4bc04f/types/json.d.ts
--- dist-a00dfc9d/types/json.d.ts
+++ dist-4bc04f/types/json.d.ts
@@ -1,2 +1,3 @@
 import type { Infer, Struct } from 'superstruct';
+import type { ObjectSchema, OmitBy, PickBy, Simplify } from 'superstruct/dist/utils';
 import type { AssertionErrorConstructor } from './assert';
@@ -9,2 +10,43 @@
 /**
+ * A helper type to make properties with `undefined` in their type optional, but
+ * not `undefined` itself.
+ *
+ * @example
+ * ```ts
+ * type Foo = JsonOptional<{ foo: string | undefined }>;
+ * // Foo is equivalent to { foo?: string }
+ * ```
+ */
+export declare type JsonOptional<Schema extends Record<string, unknown>> = {
+    [Key in keyof PickBy<Schema, undefined>]?: Exclude<Schema[Key], undefined>;
+} & {
+    [Key in keyof OmitBy<Schema, undefined>]: Schema[Key];
+};
+/**
+ * A JSON object type. This is used by the `json` struct. This uses the
+ * {@link JsonOptional} helper to make properties with `undefined` in their type
+ * optional, but not `undefined` itself.
+ */
+export declare type JsonObjectType<Schema extends ObjectSchema> = Simplify<JsonOptional<{
+    [Key in keyof Schema]: Infer<Schema[Key]>;
+}>>;
+/**
+ * A struct to check if the given value is a valid JSON object.
+ *
+ * @param schema - The schema of the JSON object.
+ * @returns A struct to check if the given value is a valid JSON object.
+ */
+export declare const jsonObject: <Schema extends ObjectSchema>(schema: Schema) => Struct<Simplify<JsonOptional<{ [Key in keyof Schema]: Infer<Schema[Key]>; }>>, unknown>;
+/**
+ * A struct to check if the given value is a valid JSON object, or not present.
+ * This means that it allows an object which does not have the property, or an
+ * object which has the property and is valid, but not an object which has the
+ * property set to `undefined`.
+ *
+ * @param struct - The struct to check the value against, if present.
+ * @returns A struct to check if the given value is a valid JSON object, or not
+ * present.
+ */
+export declare const jsonOptional: <Type, Schema>(struct: Struct<Type, Schema>) => Struct<Type | undefined, null>;
+/**
  * A struct to check if the given value is a valid JSON-serializable value.
@@ -68,12 +110,7 @@
 export declare const JsonRpcErrorStruct: Struct<{
+    data?: Json;
+    stack?: string;
     code: number;
     message: string;
-    data?: Json | undefined;
-    stack?: string | undefined;
-}, {
-    code: Struct<number, null>;
-    message: Struct<string, null>;
-    data: Struct<Json | undefined, unknown>;
-    stack: Struct<string | undefined, null>;
-}>;
+}, unknown>;
 /**
@@ -93,2 +130,3 @@
 export declare const JsonRpcRequestStruct: Struct<{
+    params?: Json[] | Record<string, Json>;
     id: string | number | null;
@@ -96,11 +134,5 @@
     jsonrpc: "2.0";
-    params?: Json[] | Record<string, Json> | undefined;
-}, {
-    id: Struct<string | number | null, null>;
-    jsonrpc: Struct<"2.0", "2.0">;
-    method: Struct<string, null>;
-    params: Struct<Json[] | Record<string, Json> | undefined, null>;
-}>;
-export declare type InferWithParams<Type extends Struct<any>, Params extends JsonRpcParams> = Omit<Infer<Type>, 'params'> & {
-    params?: Exclude<Params, undefined>;
+}, unknown>;
+export declare type InferWithParams<Type extends Struct<any>, Params extends JsonRpcParams> = Infer<Type> & {
+    params?: Params;
 };
@@ -111,10 +143,6 @@
 export declare const JsonRpcNotificationStruct: Struct<{
+    params?: Json[] | Record<string, Json>;
     method: string;
     jsonrpc: "2.0";
-    params?: Json[] | Record<string, Json> | undefined;
-}, {
-    jsonrpc: Struct<"2.0", "2.0">;
-    method: Struct<string, null>;
-    params: Struct<Json[] | Record<string, Json> | undefined, null>;
-}>;
+}, unknown>;
 /**
@@ -161,6 +189,6 @@
     error?: {
+        data?: Json;
+        stack?: string;
         code: number;
         message: string;
-        data?: Json | undefined;
-        stack?: string | undefined;
     } | undefined;
@@ -171,12 +199,7 @@
     error: Struct<{
+        data?: Json;
+        stack?: string;
         code: number;
         message: string;
-        data?: Json | undefined;
-        stack?: string | undefined;
-    } | undefined, {
-        code: Struct<number, null>;
-        message: Struct<string, null>;
-        data: Struct<Json | undefined, unknown>;
-        stack: Struct<string | undefined, null>;
-    }>;
+    } | undefined, unknown>;
 }>;
@@ -192,7 +215,3 @@
     result: Json;
-}, {
-    id: Struct<string | number | null, null>;
-    jsonrpc: Struct<"2.0", "2.0">;
-    result: Struct<Json, unknown>;
-}>;
+}, unknown>;
 /**
@@ -207,7 +226,3 @@
     jsonrpc: "2.0";
-}, {
-    id: Struct<string | number | null, null>;
-    jsonrpc: Struct<"2.0", "2.0">;
-    error: Struct<JsonRpcError, unknown>;
-}>;
+}, unknown>;
 /**

src/json.ts Outdated Show resolved Hide resolved
export type ObjectOptional<Schema extends Record<string, unknown>> = {
[Key in keyof Schema as Schema[Key] extends ExactOptionalGuard
? Key
: never]?: Schema[Key] extends ExactOptionalGuard & infer Original
Copy link
Contributor

Choose a reason for hiding this comment

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

IMO, if exactOptionalPropertyTypes is disabled, the type should be left as

{ foo: string }

instead of

{ foo?: string | undefined }

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not 100% sure about it. It would make it more difficult to work with if exactOptionalPropertyTypes is disabled (which it is currently on the Snaps repo).

@legobeat
Copy link
Contributor

legobeat commented Sep 6, 2023

Resolves #139

@Mrtenz Mrtenz force-pushed the mrtenz/json-object-validation branch from 035998d to 0c35eeb Compare October 19, 2023 17:50
Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

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

I believe I understand the general gist of this PR but I did have some questions.

As a side note I'm starting to think that the struct utils should be split off at some point, or at least put under some kind of namespace. The json file has grown pretty large, and object is a pretty generic function name that doesn't really indicate how it ought to be used. Perhaps once we get utils into the core monorepo we can start to think about how best to accomplish that.

src/json.test.ts Outdated Show resolved Hide resolved
src/json.test.ts Show resolved Hide resolved
* // Foo is equivalent to { foo?: string }
* ```
*/
export type ObjectOptional<Schema extends Record<string, unknown>> = {
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like this type essentially converts properties that should be optional (based on the fact that they can be undefined) so that they actually are. I don't know, however, that I would be able to guess that from this name. I see that Superstruct already has a type called Optionalize. What does that type do that this doesn't do? What do you think about a name like NormalizeOptionals? Then again, if this name is patterned after something else I'm not aware of, let me know.

Copy link
Member Author

Choose a reason for hiding this comment

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

Superstruct's Optionalize doesn't work with exact optionals. This is basically a copy of that, but also checks if the property has a ExactOptionalGuard, in which case the type will be inferred as key?: Type, instead of key?: Type | undefined. It's a bit hard to read 😅 I don't expect consumers of utils to use this type directly, so I don't think the name really matters?

Copy link
Contributor

Choose a reason for hiding this comment

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

Gotcha. Do you think it's something we want to export in that case then? If you plan on using it in Snaps, that's fine, just curious.

Copy link
Contributor

Choose a reason for hiding this comment

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

Er, I guess that's a silly question. I can see how they would be useful, so never mind!

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it's good practice to export any "helper" types used. There are rare cases (usually in tests) where you may need to reproduce a certain type, and it helps if those are exported.

src/json.ts Outdated Show resolved Hide resolved

declare const exactOptionalSymbol: unique symbol;
type ExactOptionalGuard = {
_exactOptionalGuard?: typeof exactOptionalSymbol;
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you explain how this type is used? I see that it's referred to above, but where does this property get set?

Copy link
Member Author

Choose a reason for hiding this comment

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

This property is never set: It's only used for inferring types. It's added to the type of the exact optional struct, and used by ObjectOptional to infer the proper type.

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay. This is advanced! I can see how that works though.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah it's definitely advanced. We use a unique symbol type to ensure that the type cannot be replicated in any way. unique symbols are guaranteed to be unique:

declare const a: unique symbol;
declare const b: unique symbol;

// TS2367: This condition will always return  false  since the types  typeof a  and  typeof b  have
// no overlap.
a === b;

Mrtenz and others added 3 commits October 19, 2023 21:45
mcmire
mcmire previously approved these changes Oct 19, 2023
Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

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

I'm good with these changes — they seem useful! The leftover comments I have are nitpicks.

Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

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

Looks good!

@Mrtenz Mrtenz merged commit 0d68084 into main Oct 19, 2023
16 checks passed
@Mrtenz Mrtenz deleted the mrtenz/json-object-validation branch October 19, 2023 20:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants