Skip to content

Commit

Permalink
Schema: add { exact: true } optional argument to the partial API,… (
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Feb 14, 2024
1 parent e572b07 commit dbff62c
Show file tree
Hide file tree
Showing 10 changed files with 449 additions and 196 deletions.
45 changes: 45 additions & 0 deletions .changeset/fast-eels-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@effect/schema": patch
---

add `{ exact: true }` optional argument to the `partial` API, mirroring the implementation in the `optional` API, closes #2140

The `partial` operation allows you to make all properties within a schema optional.

By default, the `partial` operation adds a union with `undefined` to the types. If you wish to avoid this, you can opt-out by passing a `{ exact: true }` argument to the `partial` operation.

**Example**

```ts
import * as S from "@effect/schema/Schema";

/*
const schema: S.Schema<{
readonly a?: string | undefined;
}, {
readonly a?: string | undefined;
}, never>
*/
const schema = S.partial(S.struct({ a: S.string }));

S.decodeUnknownSync(schema)({ a: "a" }); // ok
S.decodeUnknownSync(schema)({ a: undefined }); // ok

/*
const exact: S.Schema<{
readonly a?: string;
}, {
readonly a?: string;
}, never>
*/
const exactSchema = S.partial(S.struct({ a: S.string }), { exact: true });

S.decodeUnknownSync(exactSchema)({ a: "a" }); // ok
S.decodeUnknownSync(exactSchema)({ a: undefined });
/*
throws:
Error: { a?: string }
└─ ["a"]
└─ Expected a string, actual undefined
*/
```
35 changes: 33 additions & 2 deletions packages/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2082,11 +2082,42 @@ S.struct({ a: S.string, b: S.number, c: S.boolean }).pipe(S.omit("a", "c"));

The `partial` operation makes all properties within a schema optional.

By default, the `partial` operation adds a union with `undefined` to the types. If you wish to avoid this, you can opt-out by passing a `{ exact: true }` argument to the `partial` operation.

**Example**

```ts
import * as S from "@effect/schema/Schema";

// Schema<{ readonly a?: string; readonly b?: number; }>
S.partial(S.struct({ a: S.string, b: S.number }));
/*
const schema: S.Schema<{
readonly a?: string | undefined;
}, {
readonly a?: string | undefined;
}, never>
*/
const schema = S.partial(S.struct({ a: S.string }));

S.decodeUnknownSync(schema)({ a: "a" }); // ok
S.decodeUnknownSync(schema)({ a: undefined }); // ok

/*
const exact: S.Schema<{
readonly a?: string;
}, {
readonly a?: string;
}, never>
*/
const exactSchema = S.partial(S.struct({ a: S.string }), { exact: true });

S.decodeUnknownSync(exactSchema)({ a: "a" }); // ok
S.decodeUnknownSync(exactSchema)({ a: undefined });
/*
throws:
Error: { a?: string }
└─ ["a"]
└─ Expected a string, actual undefined
*/
```

## Required
Expand Down
4 changes: 2 additions & 2 deletions packages/schema/dtslint/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,14 @@ aContext.pipe(Schema.brand("a"))
// ---------------------------------------------

// $ExpectType Schema<{ readonly a?: string; readonly b?: number; }, { readonly a?: string; readonly b?: number; }, "a" | "b">
Schema.partial(Schema.struct({ a: aContext, b: bContext }))
Schema.partial(Schema.struct({ a: aContext, b: bContext }), { exact: true })

// ---------------------------------------------
// required
// ---------------------------------------------

// $ExpectType Schema<{ readonly a: string; readonly b: number; }, { readonly a: string; readonly b: number; }, "a" | "b">
Schema.required(Schema.partial(Schema.struct({ a: aContext, b: bContext })))
Schema.required(Schema.partial(Schema.struct({ a: aContext, b: bContext }), { exact: true }))

// ---------------------------------------------
// mutable
Expand Down
8 changes: 7 additions & 1 deletion packages/schema/dtslint/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,15 @@ pipe(S.NumberFromString, S.int(), S.brand("Int"))
// ---------------------------------------------

// $ExpectType Schema<{ readonly a?: string; readonly b?: number; }, { readonly a?: string; readonly b?: number; }, never>
S.partial(S.struct({ a: S.string, b: S.number }))
S.partial(S.struct({ a: S.string, b: S.number }), { exact: true })

// $ExpectType Schema<{ readonly a?: string; readonly b?: number; }, { readonly a?: string; readonly b?: string; }, never>
S.partial(S.struct({ a: S.string, b: S.NumberFromString }), { exact: true })

// $ExpectType Schema<{ readonly a?: string | undefined; readonly b?: number | undefined; }, { readonly a?: string | undefined; readonly b?: number | undefined; }, never>
S.partial(S.struct({ a: S.string, b: S.number }))

// $ExpectType Schema<{ readonly a?: string | undefined; readonly b?: number | undefined; }, { readonly a?: string | undefined; readonly b?: string | undefined; }, never>
S.partial(S.struct({ a: S.string, b: S.NumberFromString }))

// ---------------------------------------------
Expand Down
24 changes: 14 additions & 10 deletions packages/schema/src/AST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1429,16 +1429,20 @@ export const pick = (ast: AST, keys: ReadonlyArray<PropertyKey>): TypeLiteral =>
export const omit = (ast: AST, keys: ReadonlyArray<PropertyKey>): TypeLiteral =>
pick(ast, getPropertyKeys(ast).filter((name) => !keys.includes(name)))

/** @internal */
export const orUndefined = (ast: AST): AST => createUnion([ast, undefinedKeyword])

/**
* Equivalent at runtime to the built-in TypeScript utility type `Partial`.
*
* @since 1.0.0
*/
export const partial = (ast: AST): AST => {
export const partial = (ast: AST, options?: { readonly exact: true }): AST => {
const exact = options?.exact === true
switch (ast._tag) {
case "Tuple":
return createTuple(
ast.elements.map((e) => createElement(e.type, true)),
ast.elements.map((e) => createElement(exact ? e.type : orUndefined(e.type), true)),
pipe(
ast.rest,
Option.map((rest) => [createUnion([...rest, undefinedKeyword])])
Expand All @@ -1447,22 +1451,23 @@ export const partial = (ast: AST): AST => {
)
case "TypeLiteral":
return createTypeLiteral(
ast.propertySignatures.map((f) => createPropertySignature(f.name, f.type, true, f.isReadonly, f.annotations)),
ast.indexSignatures
ast.propertySignatures.map((ps) =>
createPropertySignature(ps.name, exact ? ps.type : orUndefined(ps.type), true, ps.isReadonly, ps.annotations)
),
ast.indexSignatures.map((is) => createIndexSignature(is.parameter, orUndefined(is.type), is.isReadonly))
)
case "Union":
return createUnion(ast.types.map((member) => partial(member)))
return createUnion(ast.types.map((member) => partial(member, options)))
case "Suspend":
return createSuspend(() => partial(ast.f()))
return createSuspend(() => partial(ast.f(), options))
case "Declaration":
throw new Error("`partial` cannot handle declarations")
case "Refinement":
throw new Error("`partial` cannot handle refinements")
case "Transform":
throw new Error("`partial` cannot handle transformations")
default:
return ast
}
return ast
}

/**
Expand Down Expand Up @@ -1499,9 +1504,8 @@ export const required = (ast: AST): AST => {
throw new Error("`required` cannot handle refinements")
case "Transform":
throw new Error("`required` cannot handle transformations")
default:
return ast
}
return ast
}

/**
Expand Down
23 changes: 16 additions & 7 deletions packages/schema/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@ export const nullable = <A, I, R>(self: Schema<A, I, R>): Schema<A | null, I | n
*/
export const orUndefined = <A, I, R>(
self: Schema<A, I, R>
): Schema<A | undefined, I | undefined, R> => union(_undefined, self)
): Schema<A | undefined, I | undefined, R> => make(AST.orUndefined(self.ast))

/**
* @category combinators
Expand Down Expand Up @@ -1315,10 +1315,10 @@ export const pluck: {
): Schema<A[K], I, R> => {
if (options && options.transformation == false) {
const ps = AST.getPropertyKeyIndexedAccess(schema.ast, key)
return make(ps.isOptional ? AST.createUnion([AST.undefinedKeyword, ps.type]) : ps.type)
return make(ps.isOptional ? AST.orUndefined(ps.type) : ps.type)
} else {
const ps = AST.getPropertyKeyIndexedAccess(to(schema).ast, key)
const value = make<A[K], A[K], R>(ps.isOptional ? AST.createUnion([AST.undefinedKeyword, ps.type]) : ps.type)
const value = make<A[K], A[K], R>(ps.isOptional ? AST.orUndefined(ps.type) : ps.type)
return transform(
schema,
value,
Expand Down Expand Up @@ -1400,17 +1400,26 @@ export const brand =
* @category combinators
* @since 1.0.0
*/
export const partial = <A, I, R>(
self: Schema<A, I, R>
): Schema<Simplify<Partial<A>>, Simplify<Partial<I>>, R> => make(AST.partial(self.ast))
export const partial: {
<A, I, R>(
self: Schema<A, I, R>,
options: { readonly exact: true }
): Schema<{ [K in keyof A]?: A[K] }, { [K in keyof I]?: I[K] }, R>
<A, I, R>(
self: Schema<A, I, R>
): Schema<{ [K in keyof A]?: A[K] | undefined }, Simplify<{ [K in keyof I]?: I[K] | undefined }>, R>
} = <A, I, R>(
self: Schema<A, I, R>,
options?: { readonly exact: true }
): Schema<Partial<A>, Partial<I>, R> => make(AST.partial(self.ast, options))

/**
* @category combinators
* @since 1.0.0
*/
export const required = <A, I, R>(
self: Schema<A, I, R>
): Schema<Simplify<Required<A>>, Simplify<Required<I>>, R> => make(AST.required(self.ast))
): Schema<{ [K in keyof A]-?: A[K] }, { [K in keyof I]-?: I[K] }, R> => make(AST.required(self.ast))

/**
* Creates a new schema with shallow mutability applied to its properties.
Expand Down
Loading

0 comments on commit dbff62c

Please sign in to comment.