Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type {
PartialObjectSchema,
PickBy,
Simplify,
ExactOptionalize,
StructSchema,
TupleSchema,
UnionToIntersection,
Expand Down
54 changes: 44 additions & 10 deletions src/struct.ts
Original file line number Diff line number Diff line change
@@ -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, Schema> = {
type: string;
schema: Schema;
coercer?: Coercer | undefined;
validator?: Validator | undefined;
refiner?: Refiner<Type> | undefined;
entries?: Struct<Type, Schema>['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<Type = unknown, Schema = unknown> {
// eslint-disable-next-line @typescript-eslint/naming-convention
readonly TYPE!: Type;
Expand All @@ -28,14 +36,7 @@ export class Struct<Type = unknown, Schema = unknown> {
context: Context,
) => Iterable<[string | number, unknown, Struct<any> | Struct<never>]>;

constructor(props: {
type: string;
schema: Schema;
coercer?: Coercer | undefined;
validator?: Validator | undefined;
refiner?: Refiner<Type> | undefined;
entries?: Struct<Type, Schema>['entries'] | undefined;
}) {
constructor(props: StructParams<Type, Schema>) {
const {
type,
schema,
Expand Down Expand Up @@ -124,6 +125,39 @@ export class Struct<Type = unknown, Schema = unknown> {
}
}

// String instead of a Symbol in case of multiple different versions of this library.
const ExactOptionalBrand = 'EXACT_OPTIONAL';

/**
* An `ExactOptionalStruct` is a `Struct` that is used to create exactly optional
* properties of `object()` structs.
*/
export class ExactOptionalStruct<
Type = unknown,
Schema = unknown,
> extends Struct<Type, Schema> {
// 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
Copy link
Member

Choose a reason for hiding this comment

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

Can you add a comment to clarify why this is necessary?

Copy link
Member Author

Choose a reason for hiding this comment

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

readonly brand: typeof ExactOptionalBrand;

constructor(props: StructParams<Type, Schema>) {
super({
...props,
type: `exact optional ${props.type}`,
});
this.brand = ExactOptionalBrand;
}

static isExactOptional(value: unknown): value is ExactOptionalStruct {
return (
isObject(value) && 'brand' in value && value.brand === ExactOptionalBrand
);
}
}

/**
* Assert that a value passes a struct, throwing if it doesn't.
*
Expand Down
26 changes: 25 additions & 1 deletion src/structs/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Infer } from '../struct.js';
import { Struct } from '../struct.js';
import { ExactOptionalStruct, Struct } from '../struct.js';
import type {
ObjectSchema,
ObjectType,
Expand Down Expand Up @@ -457,6 +457,14 @@ export function object<Schema extends ObjectSchema>(

for (const key of knowns) {
unknowns.delete(key);
const propertySchema = schema[key];
if (
ExactOptionalStruct.isExactOptional(propertySchema) &&
!Object.prototype.hasOwnProperty.call(value, key)
) {
continue;
}

yield [key, value[key], schema[key] as Struct<any>];
}

Expand Down Expand Up @@ -493,6 +501,22 @@ export function optional<Type, Schema>(
});
}

/**
* 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 exactly optional properties of `object()`
* structs.
*/
export function exactOptional<Type, Schema>(
struct: Struct<Type, Schema>,
): ExactOptionalStruct<Type, Schema> {
return new ExactOptionalStruct(struct);
}

/**
* Ensure that a value is an object with keys and values of specific types, but
* without ensuring any specific shape of properties.
Expand Down
66 changes: 48 additions & 18 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
ExactOptionalStruct,
} from './struct.js';

/**
* Check if a value is an iterator.
Expand Down Expand Up @@ -336,38 +343,58 @@ export type ObjectSchema = Record<string, Struct<any, any>>;
* Infer a type from an object struct schema.
*/
export type ObjectType<Schema extends ObjectSchema> = Simplify<
Optionalize<{ [K in keyof Schema]: Infer<Schema[K]> }>
// ExactOptionalize first ensures that properties of `exactOptional()` structs
// are optional, then Optionalize ensures that properties that can have the
// value `undefined` are optional.
Optionalize<ExactOptionalize<Schema>>
>;

/**
* Omit properties from a type that extend from a specific type.
* Make properties of `exactOptional()` structs optional.
*/
export type ExactOptionalize<Schema extends ObjectSchema> = {
[K in keyof OmitExactOptional<Schema>]: Infer<OmitExactOptional<Schema>[K]>;
} & {
[K in keyof PickExactOptional<Schema>]?: Infer<PickExactOptional<Schema>[K]>;
};

export type OmitBy<Type, Value> = Omit<
Type,
type OmitExactOptional<Schema extends ObjectSchema> = Omit<
Schema,
{
[Key in keyof Type]: Value extends Extract<Type[Key], Value> ? Key : never;
}[keyof Type]
[K in keyof Schema]: Schema[K] extends ExactOptionalStruct<any, any>
? K
: never;
}[keyof Schema]
>;

type PickExactOptional<Schema extends ObjectSchema> = Pick<
Schema,
{
[K in keyof Schema]: Schema[K] extends ExactOptionalStruct<any, any>
? K
: never;
}[keyof Schema]
>;

/**
* Normalize properties of a type that allow `undefined` to make them optional.
* Make properties that can have the value `undefined` optional.
*/
export type Optionalize<Schema extends object> = OmitBy<Schema, undefined> &
Partial<PickBy<Schema, undefined>>;

/**
* Transform an object schema type to represent a partial.
* Omit properties from a type that extend from a specific type.
*/

export type PartialObjectSchema<Schema extends ObjectSchema> = {
[K in keyof Schema]: Struct<Infer<Schema[K]> | undefined>;
};
export type OmitBy<Type, Value> = Omit<
Type,
{
[Key in keyof Type]: Value extends Extract<Type[Key], Value> ? Key : never;
}[keyof Type]
>;

/**
* Pick properties from a type that extend from a specific type.
*/

export type PickBy<Type, Value> = Pick<
Type,
{
Expand All @@ -376,9 +403,15 @@ export type PickBy<Type, Value> = Pick<
>;

/**
* Simplifies a type definition to its most basic representation.
* Transform an object schema type to represent a partial.
*/
export type PartialObjectSchema<Schema extends ObjectSchema> = {
[K in keyof Schema]: Struct<Infer<Schema[K]> | undefined>;
};

/**
* Simplifies a type definition to its most basic representation.
*/
export type Simplify<Type> = Type extends any[] | Date
? Type
: // eslint-disable-next-line @typescript-eslint/ban-types
Expand All @@ -391,7 +424,6 @@ export type If<Condition extends boolean, Then, Else> = Condition extends true
/**
* A schema for any type of struct.
*/

export type StructSchema<Type> = [Type] extends [string | undefined | null]
? [Type] extends [IsMatch<Type, string | undefined | null>]
? null
Expand Down Expand Up @@ -442,7 +474,6 @@ export type TupleSchema<Type> = { [K in keyof Type]: Struct<Type[K]> };
/**
* Shorthand type for matching any `Struct`.
*/

export type AnyStruct = Struct<any, any>;

/**
Expand All @@ -451,7 +482,6 @@ export type AnyStruct = Struct<any, any>;
* 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'],
Expand Down
48 changes: 48 additions & 0 deletions test/typings/exactOptional.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
assert,
exactOptional,
string,
number,
object,
enums,
} from '../../src';
import { test } from '../index.test';

test<string | undefined>((value) => {
assert(value, exactOptional(string()));
return value;
});

test<{
a?: number;
b: string;
c?: 'a' | 'b';
d?: {
e: string;
};
f?: {
g?: {
h: string;
};
};
}>((value) => {
assert(
value,
object({
a: exactOptional(number()),
b: string(),
c: exactOptional(enums(['a', 'b'])),
d: exactOptional(
object({
e: string(),
}),
),
f: exactOptional(
object({
g: exactOptional(object({ h: string() })),
}),
),
}),
);
return value;
});
Loading