From 617f20a59060b09fd4b0285774f8c58cf855d262 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Thu, 27 Mar 2025 10:59:25 -0700 Subject: [PATCH 1/7] feat: Add strictOptional struct Adds a new struct `strictOptional` that enables strictly optional properties on `object` structs. --- src/struct.ts | 30 +++++++++++++++++++++++++++ src/structs/types.ts | 28 ++++++++++++++++++++++++- src/utils.ts | 38 ++++++++++++++++++++++++++++++++-- test/typings/object.ts | 10 ++++++++- test/typings/strictOptional.ts | 38 ++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 test/typings/strictOptional.ts diff --git a/src/struct.ts b/src/struct.ts index bb2d5730..e91e1eeb 100644 --- a/src/struct.ts +++ b/src/struct.ts @@ -124,6 +124,36 @@ export class Struct { } } +// String instead of a Symbol in case of multiple different versions of this library. +const StrictOptionalBrand = 'STRICT_OPTIONAL'; + +/** + * A `StrictOptionalStruct` is a `Struct` that is used to create strictly optional properties of `object()` + * structs. + */ +export class StrictOptionalStruct< + Type = unknown, + Schema = unknown, +> extends Struct { + // eslint-disable-next-line no-restricted-syntax + private readonly brand: typeof StrictOptionalBrand; + + constructor(props: { type: string; schema: Schema }) { + super(props); + this.brand = StrictOptionalBrand; + } + + static isStrictOptional(value: Struct): value is StrictOptionalStruct { + return ( + typeof value === 'object' && + value !== null && + 'brand' in value && + // @ts-expect-error TypeScript is failing to infer that the property exists. + value.brand === StrictOptionalBrand + ); + } +} + /** * Assert that a value passes a struct, throwing if it doesn't. * diff --git a/src/structs/types.ts b/src/structs/types.ts index ba930abf..18c672c8 100644 --- a/src/structs/types.ts +++ b/src/structs/types.ts @@ -1,5 +1,5 @@ import type { Infer } from '../struct.js'; -import { Struct } from '../struct.js'; +import { StrictOptionalStruct, Struct } from '../struct.js'; import type { ObjectSchema, ObjectType, @@ -457,6 +457,14 @@ export function object( for (const key of knowns) { unknowns.delete(key); + const propertySchema = schema[key] as Struct; + if ( + StrictOptionalStruct.isStrictOptional(propertySchema) && + !Object.prototype.hasOwnProperty.call(value, key) + ) { + continue; + } + yield [key, value[key], schema[key] as Struct]; } @@ -493,6 +501,24 @@ export function optional( }); } +/** + * Augment a struct such that, if it is the property of an object, it is strictly optional. + * In other words, it is either present with the correct type, or not present at all. + * + * NOTE: Only intended for use with `object()` structs. + * + * @param struct - The struct to augment. + * @returns A new struct that can be used to create strictly optional properties of `object()` + * structs. + */ +export function strictOptional( + struct: Struct, +): StrictOptionalStruct { + return new StrictOptionalStruct({ + ...struct, + }); +} + /** * Ensure that a value is an object with keys and values of specific types, but * without ensuring any specific shape of properties. diff --git a/src/utils.ts b/src/utils.ts index 282a0e74..b0b46779 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,12 @@ import type { Failure } from './error.js'; -import type { Struct, Infer, Result, Context, Describe } from './struct.js'; +import type { + Struct, + Infer, + Result, + Context, + Describe, + StrictOptionalStruct, +} from './struct.js'; /** * Check if a value is an iterator. @@ -336,9 +343,36 @@ export type ObjectSchema = Record>; * Infer a type from an object struct schema. */ export type ObjectType = Simplify< - Optionalize<{ [K in keyof Schema]: Infer }> + // Optionalize }>> + Optionalize> >; +type OmitStrictOptional = Omit< + Schema, + { + [K in keyof Schema]: Schema[K] extends StrictOptionalStruct + ? K + : never; + }[keyof Schema] +>; + +type PickStrictOptional = Pick< + Schema, + { + [K in keyof Schema]: Schema[K] extends StrictOptionalStruct + ? K + : never; + }[keyof Schema] +>; + +type StrictOptionalize = { + [K in keyof OmitStrictOptional]: Infer[K]>; +} & { + [K in keyof PickStrictOptional]?: Infer< + PickStrictOptional[K] + >; +}; + /** * Omit properties from a type that extend from a specific type. */ diff --git a/test/typings/object.ts b/test/typings/object.ts index 90572046..518d837e 100644 --- a/test/typings/object.ts +++ b/test/typings/object.ts @@ -1,4 +1,4 @@ -import { assert, object, number, string } from '../../src'; +import { assert, object, number, string, strictOptional } from '../../src'; import { test } from '../index.test'; test>((value) => { @@ -13,3 +13,11 @@ test<{ assert(value, object({ a: number(), b: string() })); return value; }); + +test<{ + a?: number; + b: string; +}>((value) => { + assert(value, object({ a: strictOptional(number()), b: string() })); + return value; +}); diff --git a/test/typings/strictOptional.ts b/test/typings/strictOptional.ts new file mode 100644 index 00000000..727e9b29 --- /dev/null +++ b/test/typings/strictOptional.ts @@ -0,0 +1,38 @@ +import { + assert, + strictOptional, + string, + number, + object, + enums, +} from '../../src'; +import { test } from '../index.test'; + +test((value) => { + assert(value, strictOptional(string())); + return value; +}); + +test<{ + a?: number; + b: string; + c?: 'a' | 'b'; + d?: { + e: string; + }; +}>((value) => { + assert( + value, + object({ + a: strictOptional(number()), + b: string(), + c: strictOptional(enums(['a', 'b'])), + d: strictOptional( + object({ + e: string(), + }), + ), + }), + ); + return value; +}); From 9f4c709bfbe3450d10a84ed1fbc3df737ddbff0d Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 28 Mar 2025 10:59:49 -0700 Subject: [PATCH 2/7] refactor: Cleanup --- src/index.ts | 1 + src/structs/types.ts | 2 +- src/utils.ts | 54 ++++++++++++++++++++---------------------- test/typings/object.ts | 10 +------- 4 files changed, 29 insertions(+), 38 deletions(-) diff --git a/src/index.ts b/src/index.ts index d1b7c0c9..1891f950 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ export type { PartialObjectSchema, PickBy, Simplify, + StrictOptionalize, StructSchema, TupleSchema, UnionToIntersection, diff --git a/src/structs/types.ts b/src/structs/types.ts index 18c672c8..26a757dc 100644 --- a/src/structs/types.ts +++ b/src/structs/types.ts @@ -457,7 +457,7 @@ export function object( for (const key of knowns) { unknowns.delete(key); - const propertySchema = schema[key] as Struct; + const propertySchema = schema[key] as Struct; if ( StrictOptionalStruct.isStrictOptional(propertySchema) && !Object.prototype.hasOwnProperty.call(value, key) diff --git a/src/utils.ts b/src/utils.ts index b0b46779..31899a20 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -343,10 +343,23 @@ export type ObjectSchema = Record>; * Infer a type from an object struct schema. */ export type ObjectType = Simplify< - // Optionalize }>> + // StrictOptionalize first ensures that properties of `strictOptional()` structs + // are optional, then Optionalize ensures that properties that can have the + // value `undefined` are optional. Optionalize> >; +/** + * Make properties of `strictOptional()` structs optional. + */ +export type StrictOptionalize = { + [K in keyof OmitStrictOptional]: Infer[K]>; +} & { + [K in keyof PickStrictOptional]?: Infer< + PickStrictOptional[K] + >; +}; + type OmitStrictOptional = Omit< Schema, { @@ -365,18 +378,15 @@ type PickStrictOptional = Pick< }[keyof Schema] >; -type StrictOptionalize = { - [K in keyof OmitStrictOptional]: Infer[K]>; -} & { - [K in keyof PickStrictOptional]?: Infer< - PickStrictOptional[K] - >; -}; +/** + * Make properties that can have the value `undefined` optional. + */ +export type Optionalize = OmitBy & + Partial>; /** * Omit properties from a type that extend from a specific type. */ - export type OmitBy = Omit< Type, { @@ -384,24 +394,9 @@ export type OmitBy = Omit< }[keyof Type] >; -/** - * Normalize properties of a type that allow `undefined` to make them optional. - */ -export type Optionalize = OmitBy & - Partial>; - -/** - * Transform an object schema type to represent a partial. - */ - -export type PartialObjectSchema = { - [K in keyof Schema]: Struct | undefined>; -}; - /** * Pick properties from a type that extend from a specific type. */ - export type PickBy = Pick< Type, { @@ -410,9 +405,15 @@ export type PickBy = Pick< >; /** - * Simplifies a type definition to its most basic representation. + * Transform an object schema type to represent a partial. */ +export type PartialObjectSchema = { + [K in keyof Schema]: Struct | undefined>; +}; +/** + * Simplifies a type definition to its most basic representation. + */ export type Simplify = Type extends any[] | Date ? Type : // eslint-disable-next-line @typescript-eslint/ban-types @@ -425,7 +426,6 @@ export type If = Condition extends true /** * A schema for any type of struct. */ - export type StructSchema = [Type] extends [string | undefined | null] ? [Type] extends [IsMatch] ? null @@ -476,7 +476,6 @@ export type TupleSchema = { [K in keyof Type]: Struct }; /** * Shorthand type for matching any `Struct`. */ - export type AnyStruct = Struct; /** @@ -485,7 +484,6 @@ export type AnyStruct = Struct; * This is used to recursively retrieve the type from `union` `intersection` and * `tuple` structs. */ - export type InferStructTuple< Tuple extends AnyStruct[], Length extends number = Tuple['length'], diff --git a/test/typings/object.ts b/test/typings/object.ts index 518d837e..90572046 100644 --- a/test/typings/object.ts +++ b/test/typings/object.ts @@ -1,4 +1,4 @@ -import { assert, object, number, string, strictOptional } from '../../src'; +import { assert, object, number, string } from '../../src'; import { test } from '../index.test'; test>((value) => { @@ -13,11 +13,3 @@ test<{ assert(value, object({ a: number(), b: string() })); return value; }); - -test<{ - a?: number; - b: string; -}>((value) => { - assert(value, object({ a: strictOptional(number()), b: string() })); - return value; -}); From 64156a47614163f92b1a4ed0e6cd849d6cbc6d70 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 28 Mar 2025 11:50:17 -0700 Subject: [PATCH 3/7] refactor: Get rid of ts-expect-error directive --- src/struct.ts | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/struct.ts b/src/struct.ts index e91e1eeb..0d806403 100644 --- a/src/struct.ts +++ b/src/struct.ts @@ -1,14 +1,22 @@ import type { Failure } from './error.js'; import { StructError } from './error.js'; import type { StructSchema } from './utils.js'; -import { toFailures, shiftIterator, run } from './utils.js'; +import { isObject, toFailures, shiftIterator, run } from './utils.js'; + +type StructParams = { + type: string; + schema: Schema; + coercer?: Coercer | undefined; + validator?: Validator | undefined; + refiner?: Refiner | undefined; + entries?: Struct['entries'] | undefined; +}; /** * `Struct` objects encapsulate the validation logic for a specific type of * values. Once constructed, you use the `assert`, `is` or `validate` helpers to * validate unknown input data against the struct. */ - export class Struct { // eslint-disable-next-line @typescript-eslint/naming-convention readonly TYPE!: Type; @@ -28,14 +36,7 @@ export class Struct { context: Context, ) => Iterable<[string | number, unknown, Struct | Struct]>; - constructor(props: { - type: string; - schema: Schema; - coercer?: Coercer | undefined; - validator?: Validator | undefined; - refiner?: Refiner | undefined; - entries?: Struct['entries'] | undefined; - }) { + constructor(props: StructParams) { const { type, schema, @@ -138,18 +139,14 @@ export class StrictOptionalStruct< // eslint-disable-next-line no-restricted-syntax private readonly brand: typeof StrictOptionalBrand; - constructor(props: { type: string; schema: Schema }) { + constructor(props: StructParams) { super(props); this.brand = StrictOptionalBrand; } static isStrictOptional(value: Struct): value is StrictOptionalStruct { return ( - typeof value === 'object' && - value !== null && - 'brand' in value && - // @ts-expect-error TypeScript is failing to infer that the property exists. - value.brand === StrictOptionalBrand + isObject(value) && 'brand' in value && value.brand === StrictOptionalBrand ); } } From d2615617b562e93caecfeb862e0336cbccbaad51 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 28 Mar 2025 11:53:36 -0700 Subject: [PATCH 4/7] feat: Prefix strictOptional type string with "optional" --- src/struct.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/struct.ts b/src/struct.ts index 0d806403..1a7739d2 100644 --- a/src/struct.ts +++ b/src/struct.ts @@ -142,6 +142,7 @@ export class StrictOptionalStruct< constructor(props: StructParams) { super(props); this.brand = StrictOptionalBrand; + this.type = `optional ${this.type}`; } static isStrictOptional(value: Struct): value is StrictOptionalStruct { From 3f1e13bbea428ad3173d803361e777df393c6ac5 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 28 Mar 2025 13:46:46 -0700 Subject: [PATCH 5/7] refactor: Apply suggestions from code review, rename to exactOptional --- src/index.ts | 2 +- src/struct.ts | 20 +++++++++------- src/structs/types.ts | 18 +++++++------- src/utils.ts | 24 +++++++++---------- .../{strictOptional.ts => exactOptional.ts} | 20 ++++++++++++---- 5 files changed, 46 insertions(+), 38 deletions(-) rename test/typings/{strictOptional.ts => exactOptional.ts} (56%) diff --git a/src/index.ts b/src/index.ts index 1891f950..27441a8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,7 @@ export type { PartialObjectSchema, PickBy, Simplify, - StrictOptionalize, + ExactOptionalize, StructSchema, TupleSchema, UnionToIntersection, diff --git a/src/struct.ts b/src/struct.ts index 1a7739d2..e9f3a7f7 100644 --- a/src/struct.ts +++ b/src/struct.ts @@ -126,28 +126,30 @@ export class Struct { } // String instead of a Symbol in case of multiple different versions of this library. -const StrictOptionalBrand = 'STRICT_OPTIONAL'; +const ExactOptionalBrand = 'STRICT_OPTIONAL'; /** - * A `StrictOptionalStruct` is a `Struct` that is used to create strictly optional properties of `object()` + * A `ExactOptionalStruct` is a `Struct` that is used to create exactly optional properties of `object()` * structs. */ -export class StrictOptionalStruct< +export class ExactOptionalStruct< Type = unknown, Schema = unknown, > extends Struct { // eslint-disable-next-line no-restricted-syntax - private readonly brand: typeof StrictOptionalBrand; + private readonly brand: typeof ExactOptionalBrand; constructor(props: StructParams) { - super(props); - this.brand = StrictOptionalBrand; - this.type = `optional ${this.type}`; + super({ + ...props, + type: `exact optional ${props.type}`, + }); + this.brand = ExactOptionalBrand; } - static isStrictOptional(value: Struct): value is StrictOptionalStruct { + static isExactOptional(value: unknown): value is ExactOptionalStruct { return ( - isObject(value) && 'brand' in value && value.brand === StrictOptionalBrand + isObject(value) && 'brand' in value && value.brand === ExactOptionalBrand ); } } diff --git a/src/structs/types.ts b/src/structs/types.ts index 26a757dc..b40a1bd4 100644 --- a/src/structs/types.ts +++ b/src/structs/types.ts @@ -1,5 +1,5 @@ import type { Infer } from '../struct.js'; -import { StrictOptionalStruct, Struct } from '../struct.js'; +import { ExactOptionalStruct, Struct } from '../struct.js'; import type { ObjectSchema, ObjectType, @@ -457,9 +457,9 @@ export function object( for (const key of knowns) { unknowns.delete(key); - const propertySchema = schema[key] as Struct; + const propertySchema = schema[key]; if ( - StrictOptionalStruct.isStrictOptional(propertySchema) && + ExactOptionalStruct.isExactOptional(propertySchema) && !Object.prototype.hasOwnProperty.call(value, key) ) { continue; @@ -502,21 +502,19 @@ export function optional( } /** - * Augment a struct such that, if it is the property of an object, it is strictly optional. + * Augment a struct such that, if it is the property of an object, it is exactly optional. * In other words, it is either present with the correct type, or not present at all. * * NOTE: Only intended for use with `object()` structs. * * @param struct - The struct to augment. - * @returns A new struct that can be used to create strictly optional properties of `object()` + * @returns A new struct that can be used to create exactly optional properties of `object()` * structs. */ -export function strictOptional( +export function exactOptional( struct: Struct, -): StrictOptionalStruct { - return new StrictOptionalStruct({ - ...struct, - }); +): ExactOptionalStruct { + return new ExactOptionalStruct(struct); } /** diff --git a/src/utils.ts b/src/utils.ts index 31899a20..1257f8e0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,7 +5,7 @@ import type { Result, Context, Describe, - StrictOptionalStruct, + ExactOptionalStruct, } from './struct.js'; /** @@ -343,36 +343,34 @@ export type ObjectSchema = Record>; * Infer a type from an object struct schema. */ export type ObjectType = Simplify< - // StrictOptionalize first ensures that properties of `strictOptional()` structs + // ExactOptionalize first ensures that properties of `exactOptional()` structs // are optional, then Optionalize ensures that properties that can have the // value `undefined` are optional. - Optionalize> + Optionalize> >; /** - * Make properties of `strictOptional()` structs optional. + * Make properties of `exactOptional()` structs optional. */ -export type StrictOptionalize = { - [K in keyof OmitStrictOptional]: Infer[K]>; +export type ExactOptionalize = { + [K in keyof OmitExactOptional]: Infer[K]>; } & { - [K in keyof PickStrictOptional]?: Infer< - PickStrictOptional[K] - >; + [K in keyof PickExactOptional]?: Infer[K]>; }; -type OmitStrictOptional = Omit< +type OmitExactOptional = Omit< Schema, { - [K in keyof Schema]: Schema[K] extends StrictOptionalStruct + [K in keyof Schema]: Schema[K] extends ExactOptionalStruct ? K : never; }[keyof Schema] >; -type PickStrictOptional = Pick< +type PickExactOptional = Pick< Schema, { - [K in keyof Schema]: Schema[K] extends StrictOptionalStruct + [K in keyof Schema]: Schema[K] extends ExactOptionalStruct ? K : never; }[keyof Schema] diff --git a/test/typings/strictOptional.ts b/test/typings/exactOptional.ts similarity index 56% rename from test/typings/strictOptional.ts rename to test/typings/exactOptional.ts index 727e9b29..b850a671 100644 --- a/test/typings/strictOptional.ts +++ b/test/typings/exactOptional.ts @@ -1,6 +1,6 @@ import { assert, - strictOptional, + exactOptional, string, number, object, @@ -9,7 +9,7 @@ import { import { test } from '../index.test'; test((value) => { - assert(value, strictOptional(string())); + assert(value, exactOptional(string())); return value; }); @@ -20,18 +20,28 @@ test<{ d?: { e: string; }; + f?: { + g?: { + h: string; + }; + }; }>((value) => { assert( value, object({ - a: strictOptional(number()), + a: exactOptional(number()), b: string(), - c: strictOptional(enums(['a', 'b'])), - d: strictOptional( + c: exactOptional(enums(['a', 'b'])), + d: exactOptional( object({ e: string(), }), ), + f: exactOptional( + object({ + g: exactOptional(object({ h: string() })), + }), + ), }), ); return value; From 368778e865f1d9fd246e7415c67ce10236216c44 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sun, 30 Mar 2025 22:15:58 -0700 Subject: [PATCH 6/7] refactor: strict -> exact --- src/struct.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/struct.ts b/src/struct.ts index e9f3a7f7..47eec583 100644 --- a/src/struct.ts +++ b/src/struct.ts @@ -126,11 +126,11 @@ export class Struct { } // String instead of a Symbol in case of multiple different versions of this library. -const ExactOptionalBrand = 'STRICT_OPTIONAL'; +const ExactOptionalBrand = 'EXACT_OPTIONAL'; /** - * A `ExactOptionalStruct` is a `Struct` that is used to create exactly optional properties of `object()` - * structs. + * An `ExactOptionalStruct` is a `Struct` that is used to create exactly optional + * properties of `object()` structs. */ export class ExactOptionalStruct< Type = unknown, From bc66cf894e213edfcb0d03913ac47e2e7982eef2 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Tue, 1 Apr 2025 09:43:12 -0700 Subject: [PATCH 7/7] docs: Add comment explaining private brand field --- src/struct.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/struct.ts b/src/struct.ts index 47eec583..3b5a3fc5 100644 --- a/src/struct.ts +++ b/src/struct.ts @@ -136,8 +136,12 @@ export class ExactOptionalStruct< Type = unknown, Schema = unknown, > extends Struct { + // ESLint wants us to make this #-private, but we need it to be accessible by + // other versions of this library at runtime. If it were #-private, the + // implementation would break if multiple instances of this library were + // loaded at runtime. // eslint-disable-next-line no-restricted-syntax - private readonly brand: typeof ExactOptionalBrand; + readonly brand: typeof ExactOptionalBrand; constructor(props: StructParams) { super({