Skip to content
This repository has been archived by the owner on Jul 16, 2024. It is now read-only.

Commit

Permalink
add /data/Option/parseOptionals (#142)
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Mar 9, 2023
1 parent 2c156f7 commit bc30196
Show file tree
Hide file tree
Showing 9 changed files with 206 additions and 67 deletions.
5 changes: 5 additions & 0 deletions .changeset/sixty-shirts-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/schema": patch
---

add /data/Option/parseOptionals
102 changes: 85 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,11 +549,11 @@ There are two ways to define a schema for a branded type, depending on whether y
To define a schema for a branded type from scratch, you can use the `brand` combinator exported by the `@effect/schema` module. Here's an example:

```ts
import { pipe } from "@effect/data/Function"
import * as S from "@effect/schema"
import { pipe } from "@effect/data/Function";
import * as S from "@effect/schema";

const UserIdSchema = pipe(S.string, S.brand("UserId"))
type UserId = S.Infer<typeof UserIdSchema> // string & Brand<"UserId">
const UserIdSchema = pipe(S.string, S.brand("UserId"));
type UserId = S.Infer<typeof UserIdSchema>; // string & Brand<"UserId">
```

In the above example, `UserIdSchema` is a schema for the `UserId` branded type. The `brand` combinator takes a string argument that specifies the name of the brand to attach to the type.
Expand All @@ -563,18 +563,18 @@ In the above example, `UserIdSchema` is a schema for the `UserId` branded type.
If you have already defined a branded type using the `@effect/data/Brand` module, you can reuse it to define a schema using the `brand` combinator exported by the `@effect/schema/data/Brand` module. Here's an example:

```ts
import * as B from "@effect/data/Brand"
import * as B from "@effect/data/Brand";

// the existing branded type
type UserId = string & B.Brand<"UserId">
const UserId = B.nominal<UserId>()
type UserId = string & B.Brand<"UserId">;
const UserId = B.nominal<UserId>();

import { pipe } from "@effect/data/Function"
import * as S from "@effect/schema"
import { brand } from "@effect/schema/data/Brand"
import { pipe } from "@effect/data/Function";
import * as S from "@effect/schema";
import { brand } from "@effect/schema/data/Brand";

// Define a schema for the branded type
const UserIdSchema = pipe(S.string, brand(UserId))
const UserIdSchema = pipe(S.string, brand(UserId));
```

## Native enums
Expand Down Expand Up @@ -1023,7 +1023,13 @@ const decode = (s: string): PR.ParseResult<boolean> =>
: s === "false"
? PR.success(false)
: PR.failure(
PR.type(AST.createUnion([AST.createLiteral("true"), AST.createLiteral("false")]), s)
PR.type(
AST.createUnion([
AST.createLiteral("true"),
AST.createLiteral("false"),
]),
s
)
);

// define a function that converts a boolean into a string
Expand Down Expand Up @@ -1136,26 +1142,88 @@ decodeOrThrow("a"); // throws

## Option

The `option` combinator allows you to specify that a field in a schema may be either an optional value or `null`. This is useful when working with JSON data that may contain `null` values for optional fields.
### Decoding from nullable fields

In the example below, we define a schema for an object with a required `a` field of type `string` and an optional `b` field of type `number`. We use the `option` combinator to specify that the `b` field may be either a `number` or `null`.
The `option` combinator in `@effect/schema` allows you to specify that a field in a schema is of type `Option<A>` and can be decoded from a required nullable field `A | undefined | null`. This is particularly useful when working with JSON data that may contain `null` values for optional fields.

When decoding a nullable field, the `option` combinator follows these conversion rules:

- `undefined` and `null` decode to `None`
- `A` decodes to `Some<A>`

Here's an example that demonstrates how to use the `option` combinator:

```ts
import * as S from "@effect/schema";
import * as O from "@effect/data/Option";

/*
const schema: Schema<{
readonly a: string;
readonly b: Option<number>;
}>
*/
const schema = S.struct({
a: S.string,
// define a schema for Option with number values
b: S.option(S.number),
});

// decoding
const decodeOrThrow = S.decodeOrThrow(schema);
decodeOrThrow({ a: "hello", b: undefined }); // { a: "hello", b: none() }
decodeOrThrow({ a: "hello", b: null }); // { a: "hello", b: none() }
decodeOrThrow({ a: "hello", b: 1 }); // { a: "hello", b: some(1) }

decodeOrThrow({ a: "hello" }); // throws key "b" is missing

// encoding
const encodeOrThrow = S.encodeOrThrow(schema);

encodeOrThrow({ a: "hello", b: O.none() }); // { a: 'hello', b: null }
encodeOrThrow({ a: "hello", b: O.some(1) }); // { a: 'hello', b: 1 }
```

### Decoding from optional fields

When working with optional fields that contain values of type `A`, it is possible to decode them into an `Option<A>` by using the `parseOptionals` combinator.

When decoding a nullable field, the `parseOptionals` combinator follows these conversion rules:

- `undefined`, `null` and an absent value decode to `None`
- `A` decodes to `Some<A>`

Here's an example that demonstrates how to use the `parseOptionals` combinator:

```ts
import * as S from "@effect/schema";
import { parseOptionals } from "@effect/schema/data/Option";

/*
const schema: Schema<{
readonly a: string;
readonly b: Option<number>;
}>
*/
const schema = pipe(S.struct({ a: S.string }), parseOptionals({ b: S.number }));

// decoding
const decodeOrThrow = S.decodeOrThrow(schema);
decodeOrThrow({ a: "hello" }); // { a: "hello", b: none() }
decodeOrThrow({ a: "hello", b: undefined }); // { a: "hello", b: none() }
decodeOrThrow({ a: "hello", b: null }); // { a: "hello", b: none() }
decodeOrThrow({ a: "hello", b: 1 }); // { a: "hello", b: some(1) }

decodeOrThrow({ a: "hello", b: null }); // { a: "hello", b: O.none }
// encoding
const encodeOrThrow = S.encodeOrThrow(schema);

decodeOrThrow({ a: "hello", b: 1 }); // { a: "hello", b: O.some(1) }
encodeOrThrow({ a: "hello", b: O.none() }); // { a: 'hello' }
encodeOrThrow({ a: "hello", b: O.some(1) }); // { a: 'hello', b: 1 }
```

In the above example, the `parseOptionals` combinator is used to decode the optional field `b` with values of type `number` into an `Option<number>`. When decoding, `undefined`, `null` and absent values will be decoded as `none()`, and any other value will be decoded as `some(value)`.

To use `parseOptionals`, you should first define your base schema and then apply the `parseOptionals` combinator to add the fields that you want to decode into an `Option`.

## ReadonlySet

In the following section, we demonstrate how to use the `fromValues` combinator to decode a `ReadonlySet` from an array of values.
Expand Down
15 changes: 15 additions & 0 deletions docs/modules/data/Option.ts.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Added in v1.0.0
- [utils](#utils)
- [option](#option)
- [parseNullable](#parsenullable)
- [parseOptionals](#parseoptionals)

---

Expand All @@ -39,3 +40,17 @@ export declare const parseNullable: <A>(value: Schema<A>) => Schema<Option<A>>
```
Added in v1.0.0
## parseOptionals
**Signature**
```ts
export declare const parseOptionals: <Fields extends Record<string | number | symbol, Schema<any>>>(
fields: Fields
) => <A extends object>(
schema: Schema<A>
) => Schema<Spread<A & { readonly [K in keyof Fields]: Option<Infer<Fields[K]>> }>>
```
Added in v1.0.0
6 changes: 6 additions & 0 deletions dtslint/ts4.7/Option.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as _ from "@effect/schema/data/Option"
import * as S from "@effect/schema"
import { pipe } from "@effect/data/Function"

// $ExpectType Schema<{ readonly a: string; readonly b: Option<number>; }>
pipe(S.struct({ a: S.string }), _.parseOptionals({ b: S.number }))
2 changes: 1 addition & 1 deletion dtslint/ts4.7/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as S from "@effect/schema";
// brand
//

// $ExpectType Schema<number & Brand<"Int">>
// $ExpectType BrandSchema<number & Brand<"Int">>
pipe(S.number, S.int(), S.brand('Int'))

//
Expand Down
1 change: 1 addition & 0 deletions dtslint/ts4.7/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"moduleResolution": "node",
"target": "ES2021",
"lib": ["es2015"],
"skipLibCheck": true,
"paths": {
"@effect/schema": ["../../src/index.ts"],
"@effect/schema/test/*": ["../../test/*"],
Expand Down
63 changes: 62 additions & 1 deletion src/data/Option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import * as O from "@effect/data/Option"
import { IdentifierId } from "@effect/schema/annotation/AST"
import * as H from "@effect/schema/annotation/Hook"
import * as A from "@effect/schema/Arbitrary"
import * as AST from "@effect/schema/AST"
import * as I from "@effect/schema/internal/common"
import * as P from "@effect/schema/Parser"
import * as PR from "@effect/schema/ParseResult"
import type { Pretty } from "@effect/schema/Pretty"
import type { Schema } from "@effect/schema/Schema"
import type { Infer, Schema, Spread } from "@effect/schema/Schema"

const parser = <A>(value: P.Parser<A>): P.Parser<Option<A>> => {
const schema = option(value)
Expand Down Expand Up @@ -85,3 +86,63 @@ export const parseNullable = <A>(value: Schema<A>): Schema<Option<A>> =>
I.union(I._undefined, I.nullable(value)),
I.transform(option(value), O.fromNullable, O.getOrNull)
)

/**
* @since 1.0.0
*/
export const parseOptionals = <Fields extends Record<PropertyKey, Schema<any>>>(
fields: Fields
) =>
<A extends object>(
schema: Schema<A>
): Schema<Spread<A & { readonly [K in keyof Fields]: Option<Infer<Fields[K]>> }>> => {
if (AST.isTypeLiteral(schema.ast)) {
const propertySignatures = schema.ast.propertySignatures
const ownKeys = Reflect.ownKeys(fields)
const from = AST.createTypeLiteral(
propertySignatures.concat(
ownKeys.map((key) =>
AST.createPropertySignature(
key,
AST.createUnion([I._undefined.ast, I._null.ast, fields[key].ast]),
true,
true
)
)
),
schema.ast.indexSignatures
)
const to = AST.createTypeLiteral(
propertySignatures.concat(
ownKeys.map((key) =>
AST.createPropertySignature(
key,
option(fields[key]).ast,
true,
true
)
)
),
schema.ast.indexSignatures
)
const out = AST.createTransform(from, to, (o: any) => {
const out = { ...o }
for (const key of ownKeys) {
out[key] = O.fromNullable(o[key])
}
return PR.success(out)
}, (o) => {
const out = { ...o }
for (const key of ownKeys) {
if (O.isSome(o[key])) {
out[key] = o[key].value
} else {
delete out[key]
}
}
return PR.success(out)
})
return I.makeSchema(out)
}
throw new Error("`parseOptional` can only handle type literals")
}
51 changes: 3 additions & 48 deletions test/Decoder.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { pipe } from "@effect/data/Function"
import * as O from "@effect/data/Option"
import * as S from "@effect/schema"
import type { ParseOptions } from "@effect/schema/AST"
import type * as AST from "@effect/schema/AST"
import * as P from "@effect/schema/Parser"
import * as Util from "@effect/schema/test/util"

Expand Down Expand Up @@ -37,50 +36,6 @@ describe.concurrent("Decoder", () => {
)
})

it(`transform. { a: 'a' } -> { a: 'a', b: none }`, () => {
const from = S.struct({
a: S.string,
b: S.optional(S.union(S.undefined, S.nullable(S.number)))
})

const to = S.struct({
a: S.string,
b: S.option(S.number)
})

const schema = pipe(
from,
pipe(S.transform(to, (o) => ({ ...o, b: O.fromNullable(o.b) }), (o) => {
const { b: b, ...rest } = o
if (O.isSome(b)) {
rest["b"] = b.value
}
return rest
}))
)

Util.expectDecodingSuccess(schema, { a: "a" }, { a: "a", b: O.none() })
Util.expectDecodingSuccess(schema, { a: "a", b: undefined }, { a: "a", b: O.none() })
Util.expectDecodingSuccess(schema, { a: "a", b: null }, { a: "a", b: O.none() })
Util.expectDecodingSuccess(schema, { a: "a", b: 1 }, { a: "a", b: O.some(1) })

Util.expectDecodingFailureTree(
schema,
{ a: "a", b: "b" },
`1 error(s) found
└─ key "b"
├─ union member
│ └─ Expected undefined, actual "b"
├─ union member
│ └─ Expected null, actual "b"
└─ union member
└─ Expected number, actual "b"`
)

Util.expectEncodingSuccess(schema, { a: "a", b: O.none() }, { a: "a" })
Util.expectEncodingSuccess(schema, { a: "a", b: O.some(1) }, { a: "a", b: 1 })
})

it("type alias without annotations", () => {
const schema = S.typeAlias([], S.string)
Util.expectDecodingSuccess(schema, "a", "a")
Expand Down Expand Up @@ -1158,7 +1113,7 @@ describe.concurrent("Decoder", () => {
// isUnexpectedAllowed option
// ---------------------------------------------

const isUnexpectedAllowed: ParseOptions = {
const isUnexpectedAllowed: AST.ParseOptions = {
isUnexpectedAllowed: true
}

Expand Down Expand Up @@ -1249,7 +1204,7 @@ describe.concurrent("Decoder", () => {
// allErrors option
// ---------------------------------------------

const allErrors: ParseOptions = {
const allErrors: AST.ParseOptions = {
allErrors: true
}

Expand Down
28 changes: 28 additions & 0 deletions test/data/Option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,32 @@ describe.concurrent("Option", () => {
Util.expectEncodingSuccess(schema, O.none(), null)
Util.expectEncodingSuccess(schema, O.some(1), "1")
})

it("parseOptionals", () => {
expect(() => pipe(S.object, _.parseOptionals({ "b": S.number }))).toThrowError(
new Error("`parseOptional` can only handle type literals")
)

const schema = pipe(S.struct({ a: S.string }), _.parseOptionals({ b: S.number }))
Util.expectDecodingSuccess(schema, { a: "a" }, { a: "a", b: O.none() })
Util.expectDecodingSuccess(schema, { a: "a", b: undefined }, { a: "a", b: O.none() })
Util.expectDecodingSuccess(schema, { a: "a", b: null }, { a: "a", b: O.none() })
Util.expectDecodingSuccess(schema, { a: "a", b: 1 }, { a: "a", b: O.some(1) })

Util.expectDecodingFailureTree(
schema,
{ a: "a", b: "b" },
`1 error(s) found
└─ key "b"
├─ union member
│ └─ Expected undefined, actual "b"
├─ union member
│ └─ Expected null, actual "b"
└─ union member
└─ Expected number, actual "b"`
)

Util.expectEncodingSuccess(schema, { a: "a", b: O.none() }, { a: "a" })
Util.expectEncodingSuccess(schema, { a: "a", b: O.some(1) }, { a: "a", b: 1 })
})
})

0 comments on commit bc30196

Please sign in to comment.