Skip to content
Closed
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 @@ -16,3 +16,4 @@ export * from './opaque';
export * from './time';
export * from './transaction-types';
export * from './versions';
export * from './superstruct';
20 changes: 20 additions & 0 deletions src/superstruct.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<typeof exactOptionalObject>;

expectAssignable<ExactOptionalObject>({ a: 0 });
expectAssignable<ExactOptionalObject>({ a: 0, b: 'test' });
expectAssignable<ExactOptionalObject>({ a: 0, b: 'test', c: true });
expectAssignable<ExactOptionalObject>({ a: 0, b: undefined });
expectNotAssignable<ExactOptionalObject>({ a: 0, b: 'test', c: 0 });
expectNotAssignable<ExactOptionalObject>({ a: 0, b: 'test', c: undefined });
72 changes: 72 additions & 0 deletions src/superstruct.test.ts
Original file line number Diff line number Diff line change
@@ -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>)',
({ 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>)',
({ 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>)',
({ 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);
});
});
});
95 changes: 95 additions & 0 deletions src/superstruct.ts
Original file line number Diff line number Diff line change
@@ -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<T, V> = {
[K in keyof T]: Exclude<T[K], V>;
};

/**
* Make the properties of a type optional iff `exactOptionalPropertyTypes` is
* enabled, otherwise it's a no-op.
*/
export type ExactPartial<T> = 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<S extends object> = OmitBy<S, ExactOptionalTag> &
ExactPartial<ExcludeType<PickBy<S, ExactOptionalTag>, ExactOptionalTag>>;

/**
* Infer a type from an object struct schema.
*/
export type ObjectType<S extends ObjectSchema> = Simplify<
ExactOptionalize<Optionalize<{ [K in keyof S]: Infer<S[K]> }>>
>;

/**
* 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<S extends ObjectSchema>(
schema: S,
): Struct<ObjectType<S>> {
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<T, S>(
struct: Struct<T, S>,
): Struct<T | ExactOptionalTag, S> {
return new Struct({
...struct,

validator: (value, ctx) =>
!hasOptional(ctx) || struct.validator(value, ctx),

refiner: (value, ctx) =>
!hasOptional(ctx) || struct.refiner(value as T, ctx),
});
}