diff --git a/src/index.ts b/src/index.ts index 7e44f26a5..ee29930ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,3 +16,4 @@ export * from './opaque'; export * from './time'; export * from './transaction-types'; export * from './versions'; +export * from './superstruct'; diff --git a/src/superstruct.test-d.ts b/src/superstruct.test-d.ts new file mode 100644 index 000000000..601c9da13 --- /dev/null +++ b/src/superstruct.test-d.ts @@ -0,0 +1,20 @@ +import type { Infer } from 'superstruct'; +import { boolean, number, optional, string } from 'superstruct'; +import { expectAssignable, expectNotAssignable } from 'tsd'; + +import { exactOptional, object } from '.'; + +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 }); +expectAssignable({ a: 0, b: undefined }); +expectNotAssignable({ a: 0, b: 'test', c: 0 }); +expectNotAssignable({ a: 0, b: 'test', c: undefined }); diff --git a/src/superstruct.test.ts b/src/superstruct.test.ts new file mode 100644 index 000000000..dd0c5582f --- /dev/null +++ b/src/superstruct.test.ts @@ -0,0 +1,72 @@ +import { is, literal, max, number, string, union } from 'superstruct'; + +import { exactOptional, object } from '.'; + +describe('superstruct', () => { + 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 structWithUndef = object({ + foo: exactOptional(union([string(), literal(undefined)])), + }); + + it.each([ + { struct: structWithUndef, obj: {}, expected: true }, + { struct: structWithUndef, obj: { foo: undefined }, expected: true }, + { struct: structWithUndef, obj: { foo: 'hi' }, expected: true }, + { struct: structWithUndef, obj: { bar: 'hi' }, expected: false }, + { struct: structWithUndef, obj: { foo: 1 }, expected: false }, + ])( + 'returns $expected for is($obj, )', + ({ struct, obj, expected }) => { + expect(is(obj, struct)).toBe(expected); + }, + ); + + it('should support 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); + }); + }); +}); diff --git a/src/superstruct.ts b/src/superstruct.ts new file mode 100644 index 000000000..752be3688 --- /dev/null +++ b/src/superstruct.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + type Infer, + type Context, + Struct, + object as stObject, +} from 'superstruct'; +import type { + ObjectSchema, + OmitBy, + Optionalize, + PickBy, + Simplify, +} from 'superstruct/dist/utils'; + +import { hasProperty } from './misc'; + +declare const ExactOptionalSymbol: unique symbol; + +export type ExactOptionalTag = typeof ExactOptionalSymbol; + +/** + * Exclude a type from the properties of a type. + */ +export type ExcludeType = { + [K in keyof T]: Exclude; +}; + +/** + * Make the properties of a type optional iff `exactOptionalPropertyTypes` is + * enabled, otherwise it's a no-op. + */ +export type ExactPartial = undefined extends ({ a?: boolean } & { + a?: boolean | undefined; +})['a'] + ? T // Exact optional is disabled. + : { [P in keyof T]?: T[P] }; // Exact optional is enabled. + +/** + * Make optional all properties tagged as optional. + */ +export type ExactOptionalize = OmitBy & + ExactPartial, ExactOptionalTag>>; + +/** + * Infer a type from an object struct schema. + */ +export type ObjectType = Simplify< + ExactOptionalize }>> +>; + +/** + * Change the return type of a superstruct object struct to support exact + * optional properties. + * + * @param schema - The object schema. + * @returns A struct representing an object with a known set of properties. + */ +export function object( + schema: S, +): Struct> { + return stObject(schema) as any; +} + +/** + * Check the last field of a path is present. + * + * @param ctx - The context to check. + * @returns Whether the last field of a path is present. + */ +function hasOptional(ctx: Context): boolean { + const field = ctx.path[ctx.path.length - 1]; + return hasProperty(ctx.branch[ctx.branch.length - 2], field); +} + +/** + * Augment a struct to allow _exact_ optional values if the + * `exactOptionalPropertyTypes` option is set, otherwise it is a no-op. + * + * @param struct - The struct to augment. + * @returns The augmented struct. + */ +export function exactOptional( + struct: Struct, +): Struct { + return new Struct({ + ...struct, + + validator: (value, ctx) => + !hasOptional(ctx) || struct.validator(value, ctx), + + refiner: (value, ctx) => + !hasOptional(ctx) || struct.refiner(value as T, ctx), + }); +}