Skip to content

Commit db04d0b

Browse files
authored
feat: Add exactOptional struct (#32)
Adds a new struct `exactOptional` that enables strictly optional properties on `object` structs. When used with `object()` structs, `{ foo: optional(x()) }` results in `{ foo?: x | undefined }`. This makes `optional()` incompatible with e.g. the `Json` type of `@metamask/utils`. Using `exactOptional(x())`, we instead get exactly optional properties i.e. `{ foo?: x }`. The name `strictOptional` was previously considered, however `exactOptional` is in line with the `exactOptionalPropertyTypes` TypeScript configuration option, whose effect we are trying to achieve. This implementation is superior to the one currently in `@metamask/utils` in two ways: 1. The `exactOptional` struct and types should be forward-compatible with all versions of `@metamask/superstruct`. 2. By modifying the `object` struct in `@metamask/superstruct`, we avoid creating a second struct that supports `exactOptional`.
1 parent c427dbe commit db04d0b

File tree

5 files changed

+166
-29
lines changed

5 files changed

+166
-29
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type {
2222
PartialObjectSchema,
2323
PickBy,
2424
Simplify,
25+
ExactOptionalize,
2526
StructSchema,
2627
TupleSchema,
2728
UnionToIntersection,

src/struct.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import type { Failure } from './error.js';
22
import { StructError } from './error.js';
33
import type { StructSchema } from './utils.js';
4-
import { toFailures, shiftIterator, run } from './utils.js';
4+
import { isObject, toFailures, shiftIterator, run } from './utils.js';
5+
6+
type StructParams<Type, Schema> = {
7+
type: string;
8+
schema: Schema;
9+
coercer?: Coercer | undefined;
10+
validator?: Validator | undefined;
11+
refiner?: Refiner<Type> | undefined;
12+
entries?: Struct<Type, Schema>['entries'] | undefined;
13+
};
514

615
/**
716
* `Struct` objects encapsulate the validation logic for a specific type of
817
* values. Once constructed, you use the `assert`, `is` or `validate` helpers to
918
* validate unknown input data against the struct.
1019
*/
11-
1220
export class Struct<Type = unknown, Schema = unknown> {
1321
// eslint-disable-next-line @typescript-eslint/naming-convention
1422
readonly TYPE!: Type;
@@ -28,14 +36,7 @@ export class Struct<Type = unknown, Schema = unknown> {
2836
context: Context,
2937
) => Iterable<[string | number, unknown, Struct<any> | Struct<never>]>;
3038

31-
constructor(props: {
32-
type: string;
33-
schema: Schema;
34-
coercer?: Coercer | undefined;
35-
validator?: Validator | undefined;
36-
refiner?: Refiner<Type> | undefined;
37-
entries?: Struct<Type, Schema>['entries'] | undefined;
38-
}) {
39+
constructor(props: StructParams<Type, Schema>) {
3940
const {
4041
type,
4142
schema,
@@ -124,6 +125,39 @@ export class Struct<Type = unknown, Schema = unknown> {
124125
}
125126
}
126127

128+
// String instead of a Symbol in case of multiple different versions of this library.
129+
const ExactOptionalBrand = 'EXACT_OPTIONAL';
130+
131+
/**
132+
* An `ExactOptionalStruct` is a `Struct` that is used to create exactly optional
133+
* properties of `object()` structs.
134+
*/
135+
export class ExactOptionalStruct<
136+
Type = unknown,
137+
Schema = unknown,
138+
> extends Struct<Type, Schema> {
139+
// ESLint wants us to make this #-private, but we need it to be accessible by
140+
// other versions of this library at runtime. If it were #-private, the
141+
// implementation would break if multiple instances of this library were
142+
// loaded at runtime.
143+
// eslint-disable-next-line no-restricted-syntax
144+
readonly brand: typeof ExactOptionalBrand;
145+
146+
constructor(props: StructParams<Type, Schema>) {
147+
super({
148+
...props,
149+
type: `exact optional ${props.type}`,
150+
});
151+
this.brand = ExactOptionalBrand;
152+
}
153+
154+
static isExactOptional(value: unknown): value is ExactOptionalStruct {
155+
return (
156+
isObject(value) && 'brand' in value && value.brand === ExactOptionalBrand
157+
);
158+
}
159+
}
160+
127161
/**
128162
* Assert that a value passes a struct, throwing if it doesn't.
129163
*

src/structs/types.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Infer } from '../struct.js';
2-
import { Struct } from '../struct.js';
2+
import { ExactOptionalStruct, Struct } from '../struct.js';
33
import type {
44
ObjectSchema,
55
ObjectType,
@@ -457,6 +457,14 @@ export function object<Schema extends ObjectSchema>(
457457

458458
for (const key of knowns) {
459459
unknowns.delete(key);
460+
const propertySchema = schema[key];
461+
if (
462+
ExactOptionalStruct.isExactOptional(propertySchema) &&
463+
!Object.prototype.hasOwnProperty.call(value, key)
464+
) {
465+
continue;
466+
}
467+
460468
yield [key, value[key], schema[key] as Struct<any>];
461469
}
462470

@@ -493,6 +501,22 @@ export function optional<Type, Schema>(
493501
});
494502
}
495503

504+
/**
505+
* Augment a struct such that, if it is the property of an object, it is exactly optional.
506+
* In other words, it is either present with the correct type, or not present at all.
507+
*
508+
* NOTE: Only intended for use with `object()` structs.
509+
*
510+
* @param struct - The struct to augment.
511+
* @returns A new struct that can be used to create exactly optional properties of `object()`
512+
* structs.
513+
*/
514+
export function exactOptional<Type, Schema>(
515+
struct: Struct<Type, Schema>,
516+
): ExactOptionalStruct<Type, Schema> {
517+
return new ExactOptionalStruct(struct);
518+
}
519+
496520
/**
497521
* Ensure that a value is an object with keys and values of specific types, but
498522
* without ensuring any specific shape of properties.

src/utils.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import type { Failure } from './error.js';
2-
import type { Struct, Infer, Result, Context, Describe } from './struct.js';
2+
import type {
3+
Struct,
4+
Infer,
5+
Result,
6+
Context,
7+
Describe,
8+
ExactOptionalStruct,
9+
} from './struct.js';
310

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

342352
/**
343-
* Omit properties from a type that extend from a specific type.
353+
* Make properties of `exactOptional()` structs optional.
344354
*/
355+
export type ExactOptionalize<Schema extends ObjectSchema> = {
356+
[K in keyof OmitExactOptional<Schema>]: Infer<OmitExactOptional<Schema>[K]>;
357+
} & {
358+
[K in keyof PickExactOptional<Schema>]?: Infer<PickExactOptional<Schema>[K]>;
359+
};
345360

346-
export type OmitBy<Type, Value> = Omit<
347-
Type,
361+
type OmitExactOptional<Schema extends ObjectSchema> = Omit<
362+
Schema,
348363
{
349-
[Key in keyof Type]: Value extends Extract<Type[Key], Value> ? Key : never;
350-
}[keyof Type]
364+
[K in keyof Schema]: Schema[K] extends ExactOptionalStruct<any, any>
365+
? K
366+
: never;
367+
}[keyof Schema]
368+
>;
369+
370+
type PickExactOptional<Schema extends ObjectSchema> = Pick<
371+
Schema,
372+
{
373+
[K in keyof Schema]: Schema[K] extends ExactOptionalStruct<any, any>
374+
? K
375+
: never;
376+
}[keyof Schema]
351377
>;
352378

353379
/**
354-
* Normalize properties of a type that allow `undefined` to make them optional.
380+
* Make properties that can have the value `undefined` optional.
355381
*/
356382
export type Optionalize<Schema extends object> = OmitBy<Schema, undefined> &
357383
Partial<PickBy<Schema, undefined>>;
358384

359385
/**
360-
* Transform an object schema type to represent a partial.
386+
* Omit properties from a type that extend from a specific type.
361387
*/
362-
363-
export type PartialObjectSchema<Schema extends ObjectSchema> = {
364-
[K in keyof Schema]: Struct<Infer<Schema[K]> | undefined>;
365-
};
388+
export type OmitBy<Type, Value> = Omit<
389+
Type,
390+
{
391+
[Key in keyof Type]: Value extends Extract<Type[Key], Value> ? Key : never;
392+
}[keyof Type]
393+
>;
366394

367395
/**
368396
* Pick properties from a type that extend from a specific type.
369397
*/
370-
371398
export type PickBy<Type, Value> = Pick<
372399
Type,
373400
{
@@ -376,9 +403,15 @@ export type PickBy<Type, Value> = Pick<
376403
>;
377404

378405
/**
379-
* Simplifies a type definition to its most basic representation.
406+
* Transform an object schema type to represent a partial.
380407
*/
408+
export type PartialObjectSchema<Schema extends ObjectSchema> = {
409+
[K in keyof Schema]: Struct<Infer<Schema[K]> | undefined>;
410+
};
381411

412+
/**
413+
* Simplifies a type definition to its most basic representation.
414+
*/
382415
export type Simplify<Type> = Type extends any[] | Date
383416
? Type
384417
: // eslint-disable-next-line @typescript-eslint/ban-types
@@ -391,7 +424,6 @@ export type If<Condition extends boolean, Then, Else> = Condition extends true
391424
/**
392425
* A schema for any type of struct.
393426
*/
394-
395427
export type StructSchema<Type> = [Type] extends [string | undefined | null]
396428
? [Type] extends [IsMatch<Type, string | undefined | null>]
397429
? null
@@ -442,7 +474,6 @@ export type TupleSchema<Type> = { [K in keyof Type]: Struct<Type[K]> };
442474
/**
443475
* Shorthand type for matching any `Struct`.
444476
*/
445-
446477
export type AnyStruct = Struct<any, any>;
447478

448479
/**
@@ -451,7 +482,6 @@ export type AnyStruct = Struct<any, any>;
451482
* This is used to recursively retrieve the type from `union` `intersection` and
452483
* `tuple` structs.
453484
*/
454-
455485
export type InferStructTuple<
456486
Tuple extends AnyStruct[],
457487
Length extends number = Tuple['length'],

test/typings/exactOptional.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
assert,
3+
exactOptional,
4+
string,
5+
number,
6+
object,
7+
enums,
8+
} from '../../src';
9+
import { test } from '../index.test';
10+
11+
test<string | undefined>((value) => {
12+
assert(value, exactOptional(string()));
13+
return value;
14+
});
15+
16+
test<{
17+
a?: number;
18+
b: string;
19+
c?: 'a' | 'b';
20+
d?: {
21+
e: string;
22+
};
23+
f?: {
24+
g?: {
25+
h: string;
26+
};
27+
};
28+
}>((value) => {
29+
assert(
30+
value,
31+
object({
32+
a: exactOptional(number()),
33+
b: string(),
34+
c: exactOptional(enums(['a', 'b'])),
35+
d: exactOptional(
36+
object({
37+
e: string(),
38+
}),
39+
),
40+
f: exactOptional(
41+
object({
42+
g: exactOptional(object({ h: string() })),
43+
}),
44+
),
45+
}),
46+
);
47+
return value;
48+
});

0 commit comments

Comments
 (0)