diff --git a/src/index.test.ts b/src/index.test.ts index cdf1ad3af..3298c347f 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -75,6 +75,7 @@ describe('index', () => { "createModuleLogger", "createNumber", "createProjectLogger", + "exactOptional", "getChecksumAddress", "getJsonRpcIdValidator", "getJsonSize", @@ -115,6 +116,7 @@ describe('index', () => { "jsonrpc2", "numberToBytes", "numberToHex", + "object", "parseCaipAccountId", "parseCaipChainId", "remove0x", diff --git a/src/json.test-d.ts b/src/json.test-d.ts index 0d79c24f2..b25b780d3 100644 --- a/src/json.test-d.ts +++ b/src/json.test-d.ts @@ -1,8 +1,11 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import type { Infer } from 'superstruct'; +import { boolean, number, optional, string } from 'superstruct'; import { expectAssignable, expectNotAssignable } from 'tsd'; import type { Json } from '.'; +import { exactOptional, object } from '.'; // Valid Json: @@ -134,3 +137,23 @@ class Foo { } const foo = new Foo(); expectNotAssignable(foo); + +// Object using `exactOptional`: + +const exactOptionalObject = object({ + a: number(), + b: optional(string()), + c: exactOptional(boolean()), +}); + +type ExactOptionalObject = Infer; + +expectAssignable({ a: 0 }); +expectAssignable({ a: 0, b: 'test' }); +expectAssignable({ a: 0, b: 'test', c: true }); +expectNotAssignable({ a: 0, b: 'test', c: 0 }); +expectNotAssignable({ + a: 0, + b: 'test', + c: undefined, +}); diff --git a/src/json.test.ts b/src/json.test.ts index 753023170..ab0325433 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -1,4 +1,14 @@ -import { validate, assert as superstructAssert } from 'superstruct'; +import { + validate, + assert as superstructAssert, + is, + string, + union, + literal, + max, + number, + optional, +} from 'superstruct'; import { assert, @@ -20,6 +30,8 @@ import { isJsonRpcSuccess, isPendingJsonRpcResponse, isValidJson, + object, + exactOptional, JsonStruct, } from '.'; import { @@ -39,6 +51,178 @@ jest.mock('superstruct', () => ({ assert: jest.fn(), })); +describe('object', () => { + it('validates an object', () => { + expect( + is( + { + foo: 'bar', + }, + object({ + foo: string(), + }), + ), + ).toBe(true); + + expect( + is( + { + foo: 123, + }, + object({ + foo: string(), + }), + ), + ).toBe(false); + }); + + it('validates an object with exact optional values', () => { + expect( + is( + { + foo: 'bar', + }, + object({ + foo: exactOptional(string()), + }), + ), + ).toBe(true); + + expect( + is( + {}, + object({ + foo: exactOptional(string()), + }), + ), + ).toBe(true); + + expect( + is( + { + foo: undefined, + }, + object({ + foo: exactOptional(string()), + }), + ), + ).toBe(false); + }); + + it('validates an object with other values', () => { + expect( + is( + { + foo: 123, + }, + object({ + foo: number(), + }), + ), + ).toBe(true); + + expect( + is( + { + foo: 123, + }, + object({ + foo: string(), + }), + ), + ).toBe(false); + + expect( + is( + { + foo: undefined, + }, + object({ + foo: optional(string()), + }), + ), + ).toBe(true); + + expect( + is( + { + foo: 'bar', + }, + object({ + foo: optional(string()), + }), + ), + ).toBe(true); + }); +}); + +describe('exactOptional', () => { + const simpleStruct = object({ + foo: exactOptional(string()), + }); + + it.each([ + { struct: simpleStruct, obj: {}, expected: true }, + { struct: simpleStruct, obj: { foo: undefined }, expected: false }, + { struct: simpleStruct, obj: { foo: 'hi' }, expected: true }, + { struct: simpleStruct, obj: { bar: 'hi' }, expected: false }, + { struct: simpleStruct, obj: { foo: 1 }, expected: false }, + ])( + 'returns $expected for is($obj, )', + ({ struct, obj, expected }) => { + expect(is(obj, struct)).toBe(expected); + }, + ); + + const nestedStruct = object({ + foo: object({ + bar: exactOptional(string()), + }), + }); + + it.each([ + { struct: nestedStruct, obj: { foo: {} }, expected: true }, + { struct: nestedStruct, obj: { foo: { bar: 'hi' } }, expected: true }, + { + struct: nestedStruct, + obj: { foo: { bar: undefined } }, + expected: false, + }, + ])( + 'returns $expected for is($obj, )', + ({ struct, obj, expected }) => { + expect(is(obj, struct)).toBe(expected); + }, + ); + + const structWithUndefined = object({ + foo: exactOptional(union([string(), literal(undefined)])), + }); + + it.each([ + { struct: structWithUndefined, obj: {}, expected: true }, + { struct: structWithUndefined, obj: { foo: undefined }, expected: true }, + { struct: structWithUndefined, obj: { foo: 'hi' }, expected: true }, + { struct: structWithUndefined, obj: { bar: 'hi' }, expected: false }, + { struct: structWithUndefined, obj: { foo: 1 }, expected: false }, + ])( + 'returns $expected for is($obj, )', + ({ struct, obj, expected }) => { + expect(is(obj, struct)).toBe(expected); + }, + ); + + it('supports refinements', () => { + const struct = object({ + foo: exactOptional(max(number(), 0)), + }); + + expect(is({ foo: 0 }, struct)).toBe(true); + expect(is({ foo: -1 }, struct)).toBe(true); + expect(is({ foo: 1 }, struct)).toBe(false); + }); +}); + describe('json', () => { beforeEach(() => { const actual = jest.requireActual('superstruct'); diff --git a/src/json.ts b/src/json.ts index 3bd142f75..ff6a5dc8e 100644 --- a/src/json.ts +++ b/src/json.ts @@ -1,4 +1,4 @@ -import type { Infer, Struct } from 'superstruct'; +import type { Context, Infer } from 'superstruct'; import { any, array, @@ -12,16 +12,23 @@ import { literal, nullable, number, - object, + object as superstructObject, optional, record, string, union, unknown, + Struct, } from 'superstruct'; +import type { + ObjectSchema, + Optionalize, + Simplify, +} from 'superstruct/dist/utils'; import type { AssertionErrorConstructor } from './assert'; import { assertStruct } from './assert'; +import { hasProperty } from './misc'; /** * Any JSON-compatible value. @@ -34,6 +41,117 @@ export type Json = | Json[] | { [prop: string]: Json }; +/** + * A helper type to make properties with `undefined` in their type optional, but + * not `undefined` itself. + * + * @example + * ```ts + * type Foo = ObjectOptional<{ foo: string | undefined }>; + * // Foo is equivalent to { foo?: string } + * ``` + */ +export type ObjectOptional> = { + [Key in keyof Schema as Schema[Key] extends ExactOptionalGuard + ? Key + : never]?: Schema[Key] extends ExactOptionalGuard & infer Original + ? Original + : never; +} & { + [Key in keyof Schema as Schema[Key] extends ExactOptionalGuard + ? never + : Key]: Schema[Key]; +}; + +/** + * An object type with support for exact optionals. This is used by the `object` + * struct. This uses the {@link ObjectOptional} helper to make properties with + * `undefined` in their type optional, but not `undefined` itself. + */ +export type ObjectType = Simplify< + ObjectOptional< + Optionalize<{ + [Key in keyof Schema]: Infer; + }> + > +>; + +/** + * A struct to check if the given value is a valid object, with support for + * {@link exactOptional} types. + * + * @param schema - The schema of the object. + * @returns A struct to check if the given value is an object. + */ +export const object = ( + schema: Schema, +): Struct> => + // The type is slightly different from a regular object struct, because we + // want to make properties with `undefined` in their type optional, but not + // `undefined` itself. This means that we need a type cast. + superstructObject(schema) as unknown as Struct>; + +declare const exactOptionalSymbol: unique symbol; +type ExactOptionalGuard = { + _exactOptionalGuard?: typeof exactOptionalSymbol; +}; + +/** + * Check the last field of a path is present. + * + * @param context - The context to check. + * @param context.path - The path to check. + * @param context.branch - The branch to check. + * @returns Whether the last field of a path is present. + */ +function hasOptional({ path, branch }: Context): boolean { + const field = path[path.length - 1]; + return hasProperty(branch[branch.length - 2], field); +} + +/** + * A struct which allows the property of an object to be absent, or to be present + * as long as it's valid and not set to `undefined`. + * + * This struct should be used in conjunction with the {@link object} from this + * library, to get proper type inference. + * + * @param struct - The struct to check the value against, if present. + * @returns A struct to check if the given value is valid, or not present. + * @example + * ```ts + * const struct = object({ + * foo: exactOptional(string()), + * bar: exactOptional(number()), + * baz: optional(boolean()), + * qux: unknown(), + * }); + * + * type Type = Infer; + * // Type is equivalent to: + * // { + * // foo?: string; + * // bar?: number; + * // baz?: boolean | undefined; + * // qux: unknown; + * // } + * ``` + */ +export function exactOptional( + struct: Struct, +): Struct { + return new Struct({ + ...struct, + + type: `optional ${struct.type}`, + validator: (value, context) => + !hasOptional(context) || struct.validator(value, context), + + refiner: (value, context) => + !hasOptional(context) || struct.refiner(value as Type, context), + }); +} + /** * A struct to check if the given value is finite number. Superstruct's * `number()` struct does not check if the value is finite. @@ -152,8 +270,8 @@ export type JsonRpcId = Infer; export const JsonRpcErrorStruct = object({ code: integer(), message: string(), - data: optional(JsonStruct), - stack: optional(string()), + data: exactOptional(JsonStruct), + stack: exactOptional(string()), }); /** @@ -185,14 +303,14 @@ export const JsonRpcRequestStruct = object({ id: JsonRpcIdStruct, jsonrpc: JsonRpcVersionStruct, method: string(), - params: optional(JsonRpcParamsStruct), + params: exactOptional(JsonRpcParamsStruct), }); export type InferWithParams< Type extends Struct, Params extends JsonRpcParams, -> = Omit, 'params'> & { - params?: Exclude; +> = Infer & { + params?: Params; }; /** @@ -204,7 +322,7 @@ export type JsonRpcRequest = export const JsonRpcNotificationStruct = object({ jsonrpc: JsonRpcVersionStruct, method: string(), - params: optional(JsonRpcParamsStruct), + params: exactOptional(JsonRpcParamsStruct), }); /** @@ -278,7 +396,7 @@ export function assertIsJsonRpcRequest( ); } -export const PendingJsonRpcResponseStruct = object({ +export const PendingJsonRpcResponseStruct = superstructObject({ id: JsonRpcIdStruct, jsonrpc: JsonRpcVersionStruct, result: optional(unknown()), diff --git a/src/node.test.ts b/src/node.test.ts index 1d5373f2a..c33fdc98a 100644 --- a/src/node.test.ts +++ b/src/node.test.ts @@ -78,6 +78,7 @@ describe('node', () => { "createSandbox", "directoryExists", "ensureDirectoryStructureExists", + "exactOptional", "fileExists", "forceRemove", "getChecksumAddress", @@ -120,6 +121,7 @@ describe('node', () => { "jsonrpc2", "numberToBytes", "numberToHex", + "object", "parseCaipAccountId", "parseCaipChainId", "readFile",