Skip to content
Open
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
13 changes: 6 additions & 7 deletions IDEAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,13 +307,12 @@ S.reverse(S.schema({
`S.blob`/`S.file` and `S.minSize`/`S.maxSize`/`S.size` landed as the first step
of a form-data story. What they were built to make cheap, roughly in order:

- **Widen `S.minSize` to the other containers.** The runtime already accepts any
instance whose prototype carries a `.size`, so `S.instance(Set)` and
`S.instance(Map)` work today (`specs/set-minSize.yaml` is the coverage that
proves it, and exists because a `Set` is the only `.size` carrier the spec
harness can serialize). What's missing is schemas of their own: `S.set(item)`
and `S.map(key, value)` would make the bounds discoverable rather than
reachable only through `S.instance`.
- ~~**Widen `S.minSize` to the other containers.**~~ Done: `S.set(item)` and
`S.map(key, value)` are instance schemas of their own, so the bounds are
discoverable rather than reachable only through `S.instance`. What is still
open is a JSON Schema emit for either — both are unrepresentable today, where
a `Set` could plausibly emit `{ type: "array", uniqueItems: true }` for its
wire form, and a `Map` `{ type: "array", items: { … } }` for its entries.
- **Objects, under `minProperties`/`maxProperties`.** The one container whose
size is neither `.length` nor `.size`: the check would be
`Object.keys(i).length`, which allocates — worth a spec snapshot so the cost
Expand Down
78 changes: 78 additions & 0 deletions docs/js-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
- [Discriminated unions](#discriminated-unions)
- [Converting to / from a union](#converting-to-from-a-union)
- [Records](#records)
- [Sets](#sets)
- [Maps](#maps)
- [Date](#date)
- [ISO DateTime](#iso-datetime)
- [Instance](#instance)
Expand Down Expand Up @@ -1023,6 +1025,79 @@ type NumberCache = S.Infer<typeof numberCacheSchema>;
// => { [k: string]: number }
```

## Sets

`S.set(itemSchema)` validates a `Set` and every item in it:

```ts
const tagsSchema = S.set(S.string);

type Tags = S.Infer<typeof tagsSchema>; // Set<string>

S.parser(tagsSchema)(new Set(["a", "b"])); // Set { "a", "b" }
S.parser(tagsSchema)(new Set(["a", 2])); // throws S.Error: Failed at [1]: Expected string, received 2
S.parser(tagsSchema)(["a"]); // throws S.Error: Expected Set<string>, received ["a"]
```

A failing item is located by its position, which is its insertion order.

The number of items is bounded with `S.minSize`, `S.maxSize` and `S.size`:

```ts
S.set(S.string).with(S.minSize, 1); // rejects an empty Set
```

A `Set` is not JSON, so the wire form is an array — `S.to` decodes one into a
`Set`, and the same schema reversed encodes it back:

```ts
const schema = S.array(S.string).with(S.to, S.set(S.string));

S.parser(schema)(["a", "a", "b"]); // Set { "a", "b" }
S.encoder(schema)(new Set(["a", "b"])); // ["a", "b"]
```

Items are converted along the way, so a codec on the item works in both
directions:

```ts
const schema = S.array(S.isoDateTime.with(S.to, S.date)).with(S.to, S.set(S.date));

S.parser(schema)(["2020-01-01T00:00:00.000Z"]); // Set { Date }
S.encoder(schema)(new Set([new Date("2020-01-01T00:00:00.000Z")])); // ["2020-01-01T00:00:00.000Z"]
```

## Maps

`S.map(keySchema, valueSchema)` validates a `Map`, both keys and values:

```ts
const scoresSchema = S.map(S.string, S.number);

type Scores = S.Infer<typeof scoresSchema>; // Map<string, number>

S.parser(scoresSchema)(new Map([["a", 1]])); // Map { "a" => 1 }
S.parser(scoresSchema)(new Map([["a", "1"]])); // throws S.Error: Failed at a: Expected number, received "1"
```

A failing entry is located by its key when the key schema is a string or a
number (a key that fails that check is reported as it is); any other key (an
object, a `Date`, a union) locates it by position instead, as a `Set` item is,
and so does an entry converted from an array. The number of entries is bounded
with `S.minSize`, `S.maxSize` and `S.size`, as a `Set`'s is.

Its wire form is an array of `[key, value]` entries:

```ts
const schema = S.array(S.tuple([S.string, S.number])).with(
S.to,
S.map(S.string, S.number)
);

S.parser(schema)([["a", 1]]); // Map { "a" => 1 }
S.encoder(schema)(new Map([["a", 1]])); // [["a", 1]]
```

## Date

`S.date` validates that the input is a `Date` instance and rejects Invalid Date.
Expand Down Expand Up @@ -1292,6 +1367,9 @@ S.parser(numberSetSchema)(new Set([1, 2, "3"])); // throws S.Error: At item 3 -
S.parser(numberSetSchema)([1, 2, 3]); // throws S.Error: Expected Set<number>, received [1, 2, 3]
```

> A `Set` is only the example here — [`S.set`](#sets) is built in, and validates
> items without a parser call per item.

## Recursive schemas

You can define a recursive schema in **Sury**. Unfortunately, TypeScript derives the Schema type as `unknown` so you need to explicitly specify the type and it'll start correctly typechecking.
Expand Down
35 changes: 35 additions & 0 deletions docs/rescript-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
- [Enums](#enums)
- [Converting to / from a union](#converting-to-from-a-union)
- [`list`](#list)
- [`set`](#set)
- [`map`](#map)
- [`compactColumns`](#compactcolumns)
- [`tuple`](#tuple)
- [`tuple1` - `tuple3`](#tuple1---tuple3)
Expand Down Expand Up @@ -1036,6 +1038,39 @@ let schema = S.list(S.string)

The `S.list` schema represents an array of data of a specific type which is transformed to ReScript's list data-structure.

### **`set`**

`S.t<'value> => S.t<Set.t<'value>>`

```rescript
let schema = S.set(S.string)

Set.fromArray(["Hello", "World"])->S.parseOrThrow(~to=schema)
```

Validates a `Set` and every item in it. A `Set` isn't JSON, so its wire form is
an array — `S.to` decodes one into a `Set`, and the same schema reversed encodes
it back:

```rescript
let schema = S.array(S.string)->S.to(S.set(S.string))

["Hello", "World"]->S.parseOrThrow(~to=schema)
```

### **`map`**

`(S.t<'key>, S.t<'value>) => S.t<Map.t<'key, 'value>>`

```rescript
let schema = S.map(S.string, S.int)

Map.fromArray([("Hello", 1)])->S.parseOrThrow(~to=schema)
```

Validates a `Map`, both keys and values. Its wire form is an array of
`(key, value)` entries.

### **`compactColumns`**

`S.t<'value> => S.t<array<array<'value>>>`
Expand Down
12 changes: 12 additions & 0 deletions packages/e2e/src/ppx/Ppx_Primitive_test.res
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ test("Dict of string schema from Core", t => {
t->assertEqualSchemas(myDictOfStringFromCoreSchema, S.dict(S.string))
})

@schema
type mySetOfString = Set.t<string>
test("Set of string schema", t => {
t->assertEqualSchemas(mySetOfStringSchema, S.set(S.string))
})

@schema
type myMapOfStringToInt = Map.t<string, int>
test("Map of string to int schema", t => {
t->assertEqualSchemas(myMapOfStringToIntSchema, S.map(S.string, S.int))
})

@schema
type myJson = Js.Json.t
test("Json schema", t => {
Expand Down
10 changes: 10 additions & 0 deletions packages/e2e/src/ppx/Ppx_Primitive_test.res.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ let myDictOfStringFromCoreSchema = Sury.dict(Sury.string);

Vitest.test("Dict of string schema from Core", t => U.assertEqualSchemas(t, myDictOfStringFromCoreSchema, Sury.dict(Sury.string), undefined));

let mySetOfStringSchema = Sury.set(Sury.string);

Vitest.test("Set of string schema", t => U.assertEqualSchemas(t, mySetOfStringSchema, Sury.set(Sury.string), undefined));

let myMapOfStringToIntSchema = Sury.map(Sury.string, Sury.int);

Vitest.test("Map of string to int schema", t => U.assertEqualSchemas(t, myMapOfStringToIntSchema, Sury.map(Sury.string, Sury.int), undefined));

let myJsonSchema = Sury.json;

Vitest.test("Json schema", t => U.assertEqualSchemas(t, myJsonSchema, Sury.json, undefined));
Expand Down Expand Up @@ -137,6 +145,8 @@ export {
myDictOfStringSchema,
myDictOfStringFromJsSchema,
myDictOfStringFromCoreSchema,
mySetOfStringSchema,
myMapOfStringToIntSchema,
myJsonSchema,
myJsonFromCoreSchema,
myTupleSchema,
Expand Down
11 changes: 11 additions & 0 deletions packages/sury-ppx/src/ppx/Structure.ml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ let rec generateConstrSchemaExpression {Location.txt = identifier; loc}
| Ldot (Ldot (Lident "Js", "Dict"), "t"), [item_type]
| Ldot (Lident "Dict", "t"), [item_type] ->
[%expr S.dict [%e generateCoreTypeSchemaExpression item_type]]
| Ldot (Lident "Set", "t"), [item_type]
| Ldot (Ldot (Lident "Stdlib", "Set"), "t"), [item_type] ->
[%expr S.set [%e generateCoreTypeSchemaExpression item_type]]
(* Ahead of the generic `Ldot` fallbacks below, which reject a second type
parameter — `Map.t` is the one built-in that has two. *)
| Ldot (Lident "Map", "t"), [key_type; value_type]
| Ldot (Ldot (Lident "Stdlib", "Map"), "t"), [key_type; value_type] ->
[%expr
S.map
[%e generateCoreTypeSchemaExpression key_type]
[%e generateCoreTypeSchemaExpression value_type]]
| Lident s, [] -> makeIdentExpr (generateSchemaName s)
| Lident s, [arg] ->
Exp.apply (makeIdentExpr (generateSchemaName s))
Expand Down
42 changes: 42 additions & 0 deletions packages/sury/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,48 @@ export const array: <
schema: SchemaLike<TInput, TOutput> | TDef
) => Schema<TInput[], TOutput[]>;

/**
* A `Set`, with every item validated by `schema`.
*
* A `Set` is not JSON, so the wire form is an array: `S.array(item)` decodes
* to it with {@link to}, and the same schema reversed encodes back.
*
* ```ts
* S.parser(S.set(S.string))(new Set(["a"])); // Set { "a" }
* S.parser(S.array(S.string).with(S.to, S.set(S.string)))(["a"]); // Set { "a" }
* ```
*/
export const set: <
const TDef = never,
TInput = UnknownToInput<TDef>,
TOutput = UnknownToOutput<TDef>
>(
schema: SchemaLike<TInput, TOutput> | TDef
) => Schema<Set<TInput>, Set<TOutput>>;

/**
* A `Map`, with every key validated by `key` and every value by `value`.
*
* A `Map` is not JSON, so the wire form is an array of `[key, value]` entries:
* `S.array(S.tuple([key, value]))` decodes to it with {@link to}, and the same
* schema reversed encodes back.
*
* ```ts
* S.parser(S.map(S.string, S.number))(new Map([["a", 1]])); // Map { "a" => 1 }
* ```
*/
export const map: <
const TKeyDef = never,
const TValueDef = never,
TKeyInput = UnknownToInput<TKeyDef>,
TKeyOutput = UnknownToOutput<TKeyDef>,
TValueInput = UnknownToInput<TValueDef>,
TValueOutput = UnknownToOutput<TValueDef>
>(
key: SchemaLike<TKeyInput, TKeyOutput> | TKeyDef,
value: SchemaLike<TValueInput, TValueOutput> | TValueDef
) => Schema<Map<TKeyInput, TValueInput>, Map<TKeyOutput, TValueOutput>>;

export const compactColumns: <
const TDef = never,
TInput = UnknownToInput<TDef>,
Expand Down
2 changes: 2 additions & 0 deletions packages/sury/scripts/unionFuzz/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export const FUZZ_EXPORTS: Record<string, FuzzExport> = {
lte: modify(["number", "bigint"], (S, schema) =>
schema.with(S.lte, schema.type === "bigint" ? 100n : 100),
),
map: wrap((S, inner) => S.map(S.string, inner)),
maxLength: modify(["string", "array"], (S, schema) =>
schema.with(S.maxLength, 32),
),
Expand Down Expand Up @@ -155,6 +156,7 @@ export const FUZZ_EXPORTS: Record<string, FuzzExport> = {
safe: skip("operation, not a schema factory"),
safeAsync: skip("operation, not a schema factory"),
schema: build(),
set: wrap((S, inner) => S.set(inner)),
shape: skip("output reshape; not a union-member combinator"),
size: modify(["instance"], (S, schema) => schema.with(S.size, 1)),
strict: modify(["object"], (S, schema) => S.strict(schema)),
Expand Down
38 changes: 38 additions & 0 deletions packages/sury/specs/array-async-minLength.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# yaml-language-server: $schema=./spec.schema.json
ts:
schema: 'S.array(S.string.with(S.to, S.string, { decode: { async: async (value) => value }, encode: "auto" })).with(S.minLength, 2)'
input: "[string, string, ...string[]]"
output: "[string, string, ...string[]]"
instantiations: 6299
jsonSchema:
input: '{ items: { type: "string" }, type: "array", minItems: 2 }'
fromInputType: string[]
output: '{ items: { type: "string" }, type: "array", minItems: 2 }'
fromOutputType: string[]
vs:
zod:
_skip: not-applicable
operations:
parse:
isAsync: true
expression: i=>{Array.isArray(i)||e[5](i);let v4=new Array(i.length);for(let v0=0;v0<i.length;++v0){try{let v2=i[v0];typeof v2==="string"||e[3](v2);let v1;try{v1=e[0](v2).catch(x=>e[1](x))}catch(x){e[1](x)}v4[v0]=v1.then(v1=>{typeof v1==="string"||e[2](v1);return v1}).catch(v3=>{v3.path=[v0,...v3.path];throw v3})}catch(v3){v3.path=[v0,...v3.path];throw v3}}return Promise.all(v4).then(v5=>{v5.length>1||e[4](v5);return v5})}
examples:
valid:
input: '["a", "b"]'
output: '["a", "b"]'
too-short-after-resolving:
input: '["a"]'
error: Expected string[].length >= 2, received ["a"]
decode:
isAsync: true
expression: i=>{let v3=new Array(i.length);for(let v0=0;v0<i.length;++v0){try{let v1;try{v1=e[0](i[v0]).catch(x=>e[1](x))}catch(x){e[1](x)}v3[v0]=v1.then(v1=>{typeof v1==="string"||e[2](v1);return v1}).catch(v2=>{v2.path=[v0,...v2.path];throw v2})}catch(v2){v2.path=[v0,...v2.path];throw v2}}return Promise.all(v3).then(v4=>{v4.length>1||e[3](v4);return v4})}
examples:
valid:
input: '["a", "b"]'
output: '["a", "b"]'
encode:
expression: i=>{i.length>1||e[0](i);return i}
examples:
valid:
input: '["a", "b"]'
output: '["a", "b"]'
30 changes: 30 additions & 0 deletions packages/sury/specs/array-async.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# yaml-language-server: $schema=./spec.schema.json
ts:
schema: 'S.array(S.string.with(S.to, S.string, { decode: { async: async (value) => { if (value === "bad") { throw new Error("rejected") } return value } }, encode: "auto" }))'
input: string[]
output: string[]
instantiations: 5620
jsonSchema:
input: '{ items: { type: "string" }, type: "array" }'
output: '{ items: { type: "string" }, type: "array" }'
vs:
zod: z.array(z.string().refine(async (value) => value !== "bad"))
operations:
parse:
isAsync: true
expression: i=>{Array.isArray(i)||e[4](i);let v4=new Array(i.length);for(let v0=0;v0<i.length;++v0){try{let v2=i[v0];typeof v2==="string"||e[3](v2);let v1;try{v1=e[0](v2).catch(x=>e[1](x))}catch(x){e[1](x)}v4[v0]=v1.then(v1=>{typeof v1==="string"||e[2](v1);return v1}).catch(v3=>{v3.path=[v0,...v3.path];throw v3})}catch(v3){v3.path=[v0,...v3.path];throw v3}}return Promise.all(v4)}
examples:
valid:
input: '["a", "b"]'
output: '["a", "b"]'
rejected-item-is-located:
input: '["a", "bad"]'
error: "Failed at [1]: rejected"
decode:
isAsync: true
expression: i=>{let v3=new Array(i.length);for(let v0=0;v0<i.length;++v0){try{let v1;try{v1=e[0](i[v0]).catch(x=>e[1](x))}catch(x){e[1](x)}v3[v0]=v1.then(v1=>{typeof v1==="string"||e[2](v1);return v1}).catch(v2=>{v2.path=[v0,...v2.path];throw v2})}catch(v2){v2.path=[v0,...v2.path];throw v2}}return Promise.all(v3)}
examples:
valid:
input: '["a"]'
output: '["a"]'
encode: identity
Loading
Loading