Skip to content

Commit

Permalink
Add encodedBoundSchema API (#2897)
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Jun 1, 2024
1 parent 1490aa0 commit 70cda70
Show file tree
Hide file tree
Showing 7 changed files with 305 additions and 13 deletions.
40 changes: 40 additions & 0 deletions .changeset/lucky-tips-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@effect/schema": patch
---

Add `encodedBoundSchema` API.

The `encodedBoundSchema` function is similar to `encodedSchema` but preserves the refinements up to the first transformation point in the
original schema.

**Function Signature:**

```ts
export const encodedBoundSchema = <A, I, R>(schema: Schema<A, I, R>): Schema<I>
```
The term "bound" in this context refers to the boundary up to which refinements are preserved when extracting the encoded form of a schema. It essentially marks the limit to which initial validations and structure are maintained before any transformations are applied.
**Example Usage:**
```ts
import { Schema } from "@effect/schema"

const schema = Schema.Struct({
foo: Schema.String.pipe(Schema.minLength(3), Schema.compose(Schema.Trim))
})

// The resultingEncodedBoundSchema preserves the minLength(3) refinement,
// ensuring the string length condition is enforced but omits the Trim transformation.
const resultingEncodedBoundSchema = Schema.encodedBoundSchema(schema)

// resultingEncodedBoundSchema is the same as:
Schema.Struct({
foo: Schema.String.pipe(Schema.minLength(3))
})
```

In the provided example:

- **Initial Schema**: The schema for `foo` includes a refinement to ensure strings have a minimum length of three characters and a transformation to trim the string.
- **Resulting Schema**: `resultingEncodedBoundSchema` maintains the `minLength(3)` condition, ensuring that this validation persists. However, it excludes the trimming transformation, focusing solely on the length requirement without altering the string's formatting.
107 changes: 107 additions & 0 deletions packages/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3439,6 +3439,113 @@ Schema.compose(
)
```

## Projections

### typeSchema

The `typeSchema` function allows you to extract the `Type` portion of a schema, creating a new schema that conforms to the properties defined in the original schema without considering the initial encoding or transformation processes.

**Function Signature:**

```ts
export const typeSchema = <A, I, R>(schema: Schema<A, I, R>): Schema<A>
```
**Example Usage:**
```ts
import { Schema } from "@effect/schema"

const schema = Schema.Struct({
foo: Schema.NumberFromString.pipe(Schema.greaterThanOrEqualTo(2))
})

// This creates a schema where 'foo' is strictly a number that must be greater than or equal to 2.
const resultingTypeSchema = Schema.typeSchema(schema)

// resultingTypeSchema is the same as:
Schema.Struct({
foo: Schema.Number.pipe(Schema.greaterThanOrEqualTo(2))
})
```

In this example:

- **Original Schema**: The schema for `foo` is initially defined to accept a number from a string and enforce that it is greater than or equal to 2.
- **Resulting Type Schema**: The `typeSchema` extracts only the type-related information from `foo`, simplifying it to just a number while still maintaining the constraint that it must be greater than or equal to 2.

### encodedSchema

The `encodedSchema` function allows you to extract the `Encoded` portion of a schema, creating a new schema that conforms to the properties defined in the original schema without retaining any refinements or transformations that were applied previously.

**Function Signature:**

```ts
export const encodedSchema = <A, I, R>(schema: Schema<A, I, R>): Schema<I>
```
**Example Usage:**
Attenzione che `encodedSchema` non preserva i refinements:
```ts
import { Schema } from "@effect/schema"

const schema = Schema.Struct({
foo: Schema.String.pipe(Schema.minLength(3))
})

// resultingEncodedSchema simplifies 'foo' to just a string, disregarding the minLength refinement.
const resultingEncodedSchema = Schema.encodedSchema(schema)

// resultingEncodedSchema is the same as:
Schema.Struct({
foo: Schema.String
})
```

In this example:

- **Original Schema Definition**: The `foo` field in the schema is defined as a string with a minimum length of three characters.
- **Resulting Encoded Schema**: The `encodedSchema` function simplifies the `foo` field to just a string type, effectively stripping away the `minLength` refinement.

### encodedBoundSchema

The `encodedBoundSchema` function is similar to `encodedSchema` but preserves the refinements up to the first transformation point in the
original schema.

**Function Signature:**

```ts
export const encodedBoundSchema = <A, I, R>(schema: Schema<A, I, R>): Schema<I>
```
The term "bound" in this context refers to the boundary up to which refinements are preserved when extracting the encoded form of a schema. It essentially marks the limit to which initial validations and structure are maintained before any transformations are applied.
**Example Usage:**
```ts
import { Schema } from "@effect/schema"

const schema = Schema.Struct({
foo: Schema.String.pipe(Schema.minLength(3), Schema.compose(Schema.Trim))
})

// The resultingEncodedBoundSchema preserves the minLength(3) refinement,
// ensuring the string length condition is enforced but omits the Trim transformation.
const resultingEncodedBoundSchema = Schema.encodedBoundSchema(schema)

// resultingEncodedBoundSchema is the same as:
Schema.Struct({
foo: Schema.String.pipe(Schema.minLength(3))
})
```

In the provided example:

- **Initial Schema**: The schema for `foo` includes a refinement to ensure strings have a minimum length of three characters and a transformation to trim the string.
- **Resulting Schema**: `resultingEncodedBoundSchema` maintains the `minLength(3)` condition, ensuring that this validation persists. However, it excludes the trimming transformation, focusing solely on the length requirement without altering the string's formatting.

# Declaring New Data Types

Creating schemas for new data types is crucial to defining the expected structure of information in your application. This guide explores how to declare schemas for new data types. We'll cover two important concepts: declaring schemas for primitive data types and type constructors.
Expand Down
38 changes: 25 additions & 13 deletions packages/schema/src/AST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2317,55 +2317,67 @@ function changeMap<A>(as: ReadonlyArray<A>, f: (a: A) => A): ReadonlyArray<A> {
return changed ? out : as
}

/**
* @since 1.0.0
*/
export const encodedAST = (ast: AST): AST => {
const encodedAST_ = (ast: AST, bound: boolean): AST => {
switch (ast._tag) {
case "Declaration": {
const typeParameters = changeMap(ast.typeParameters, encodedAST)
const typeParameters = changeMap(ast.typeParameters, (ast) => encodedAST_(ast, bound))
return typeParameters === ast.typeParameters ?
ast :
new Declaration(typeParameters, ast.decodeUnknown, ast.encodeUnknown, ast.annotations)
}
case "TupleType": {
const elements = changeMap(ast.elements, (e) => {
const type = encodedAST(e.type)
const type = encodedAST_(e.type, bound)
return type === e.type ? e : new Element(type, e.isOptional)
})
const rest = changeMap(ast.rest, encodedAST)
const rest = changeMap(ast.rest, (ast) => encodedAST_(ast, bound))
return elements === ast.elements && rest === ast.rest ?
ast :
new TupleType(elements, rest, ast.isReadonly, createJSONIdentifierAnnotation(ast))
}
case "TypeLiteral": {
const propertySignatures = changeMap(ast.propertySignatures, (ps) => {
const type = encodedAST(ps.type)
const type = encodedAST_(ps.type, bound)
return type === ps.type
? ps
: new PropertySignature(ps.name, type, ps.isOptional, ps.isReadonly)
})
const indexSignatures = changeMap(ast.indexSignatures, (is) => {
const type = encodedAST(is.type)
const type = encodedAST_(is.type, bound)
return type === is.type ? is : new IndexSignature(is.parameter, type, is.isReadonly)
})
return propertySignatures === ast.propertySignatures && indexSignatures === ast.indexSignatures ?
ast :
new TypeLiteral(propertySignatures, indexSignatures, createJSONIdentifierAnnotation(ast))
}
case "Union": {
const types = changeMap(ast.types, encodedAST)
const types = changeMap(ast.types, (ast) => encodedAST_(ast, bound))
return types === ast.types ? ast : Union.make(types, createJSONIdentifierAnnotation(ast))
}
case "Suspend":
return new Suspend(() => encodedAST(ast.f()), createJSONIdentifierAnnotation(ast))
case "Refinement":
return new Suspend(() => encodedAST_(ast.f(), bound), createJSONIdentifierAnnotation(ast))
case "Refinement": {
const from = encodedAST_(ast.from, bound)
if (bound) {
return from === ast.from ? ast : from
}
return from
}
case "Transformation":
return encodedAST(ast.from)
return encodedAST_(ast.from, bound)
}
return ast
}

/**
* @since 1.0.0
*/
export const encodedAST = (ast: AST): AST => encodedAST_(ast, false)
/**
* @since 1.0.0
*/
export const encodedBoundAST = (ast: AST): AST => encodedAST_(ast, true)

const toJSONAnnotations = (annotations: Annotations): object => {
const out: Record<string, unknown> = {}
for (const k of Object.getOwnPropertySymbols(annotations)) {
Expand Down
6 changes: 6 additions & 0 deletions packages/schema/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,12 @@ export declare namespace Schema {
*/
export const encodedSchema = <A, I, R>(schema: Schema<A, I, R>): SchemaClass<I> => make(AST.encodedAST(schema.ast))

/**
* @since 1.0.0
*/
export const encodedBoundSchema = <A, I, R>(schema: Schema<A, I, R>): SchemaClass<I> =>
make(AST.encodedBoundAST(schema.ast))

/**
* @since 1.0.0
*/
Expand Down
6 changes: 6 additions & 0 deletions packages/schema/test/AST/encodedAST.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import * as S from "@effect/schema/Schema"
import { describe, expect, it } from "vitest"

describe("encodedAST", () => {
it("refinements", () => {
const ast = S.String.pipe(S.minLength(2)).ast
const encodedAST = AST.encodedAST(ast)
expect(encodedAST).toBe(S.String.ast)
})

describe(`should return the same reference if the AST doesn't represent a transformation`, () => {
it("declaration (true)", () => {
const schema = S.OptionFromSelf(S.String)
Expand Down
73 changes: 73 additions & 0 deletions packages/schema/test/AST/encodedBoundAST.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as AST from "@effect/schema/AST"
import * as S from "@effect/schema/Schema"
import { describe, expect, it } from "vitest"

describe("encodedBoundAST", () => {
it("refinements", () => {
const ast = S.String.pipe(S.minLength(2)).ast
const encodedAST = AST.encodedBoundAST(ast)
expect(encodedAST === ast).toBe(true)
})

describe(`should return the same reference if the AST doesn't represent a transformation`, () => {
it("declaration (true)", () => {
const schema = S.OptionFromSelf(S.String)
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(true)
})

it("declaration (false)", () => {
const schema = S.OptionFromSelf(S.NumberFromString)
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(false)
})

it("tuple (true)", () => {
const schema = S.Tuple(S.String, S.Number)
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(true)
})

it("tuple (false)", () => {
const schema = S.Tuple(S.String, S.NumberFromString)
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(false)
})

it("array (true)", () => {
const schema = S.Array(S.Number)
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(true)
})

it("array (false)", () => {
const schema = S.Array(S.NumberFromString)
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(false)
})

it("union (true)", () => {
const schema = S.Union(S.String, S.Number)
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(true)
})

it("union (false)", () => {
const schema = S.Union(S.String, S.NumberFromString)
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(false)
})

it("struct (true)", () => {
const schema = S.Struct({ a: S.String, b: S.Number })
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(true)
})

it("struct (false)", () => {
const schema = S.Struct({ a: S.String, b: S.NumberFromString })
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(false)
})

it("record (true)", () => {
const schema = S.Record(S.String, S.Number)
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(true)
})

it("record (false)", () => {
const schema = S.Record(S.String, S.NumberFromString)
expect(AST.encodedBoundAST(schema.ast) === schema.ast).toBe(false)
})
})
})
48 changes: 48 additions & 0 deletions packages/schema/test/Schema/encodedBoundSchema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as S from "@effect/schema/Schema"
import * as Util from "@effect/schema/test/TestUtils"
import { describe, it } from "vitest"

describe("encodedBoundSchema", () => {
it("refinements", async () => {
const StringFromStruct = S.transform(
S.Struct({ value: S.String }).annotations({ identifier: "ValueStruct" }),
S.String,
{
encode: (name) => ({ value: name }),
decode: (nameInForm) => nameInForm.value
}
).annotations({ identifier: "StringFromStruct" })

const Handle = S.String.pipe(
S.minLength(3),
S.pattern(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/)
).annotations({ identifier: "Handle" })

const FullSchema = S.Struct({
names: S.NonEmptyArray(StringFromStruct),
handle: Handle
}).annotations({ identifier: "FullSchema" })

const schema = S.encodedBoundSchema(FullSchema)

await Util.expectDecodeUnknownSuccess(schema, {
names: [{ value: "Name #1" }],
handle: "user123"
})

await Util.expectDecodeUnknownFailure(
schema,
{
names: [{ value: "Name #1" }],
handle: "aa"
},
`{ readonly names: readonly [ValueStruct, ...ValueStruct[]]; readonly handle: Handle }
└─ ["handle"]
└─ Handle
└─ From side refinement failure
└─ a string at least 3 character(s) long
└─ Predicate refinement failure
└─ Expected a string at least 3 character(s) long, actual "aa"`
)
})
})

0 comments on commit 70cda70

Please sign in to comment.