Skip to content

Commit 9c1a960

Browse files
authored
Add Schema.declare and Schema.declareConstructor docs to SCHEMA.md (#1770)
1 parent 58217d3 commit 9c1a960

File tree

1 file changed

+205
-0
lines changed

1 file changed

+205
-0
lines changed

packages/effect/SCHEMA.md

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1991,6 +1991,211 @@ console.log(matcher({ type: "B", b: 1 })) // This is a B: 1
19911991
console.log(matcher({ type: "C", c: true })) // This is a C: true
19921992
```
19931993

1994+
# Declaring Custom Types
1995+
1996+
When none of the built-in schema combinators fit your data type, use `Schema.declare` or `Schema.declareConstructor`.
1997+
1998+
## `Schema.declare` (non-parametric types)
1999+
2000+
`Schema.declare` creates a schema from a **type guard** — a function that checks whether an unknown value is of a given type. This is useful when you have a type that doesn't fit the built-in combinators (like `Struct`, `Array`, etc.) and you need to teach Schema how to recognize it.
2001+
2002+
```ts
2003+
Schema.declare<T>(
2004+
is: (u: unknown) => u is T,
2005+
annotations?: { expected?: string; toCodecJson?: ...; ... }
2006+
)
2007+
```
2008+
2009+
The first argument is your type guard. Schema will call it on any input value: if it returns `true`, decoding succeeds; if `false`, decoding fails.
2010+
2011+
**Example** (Creating a schema for `URL`)
2012+
2013+
```ts
2014+
import { Schema } from "effect"
2015+
2016+
// The type guard tells Schema how to recognize a URL instance
2017+
const URLSchema = Schema.declare(
2018+
(u): u is URL => u instanceof URL
2019+
)
2020+
2021+
console.log(String(Schema.decodeUnknownExit(URLSchema)(new URL("https://example.com"))))
2022+
// Success(https://example.com/)
2023+
2024+
console.log(String(Schema.decodeUnknownExit(URLSchema)(null)))
2025+
// Failure(Cause([Fail(SchemaError(Expected <Declaration>, got null))]))
2026+
```
2027+
2028+
> **Tip**: For simple `instanceof` checks, prefer `Schema.instanceOf(URL)`, it wraps `Schema.declare` with an `instanceof` guard automatically.
2029+
2030+
### Customizing the error message with `expected`
2031+
2032+
The default error message `Expected <Declaration>` is not very descriptive. Use the `expected` annotation (second argument) to provide a human-readable name for your type.
2033+
2034+
**Example** (Adding an `expected` annotation)
2035+
2036+
```ts
2037+
import { Schema } from "effect"
2038+
2039+
const URLSchema = Schema.declare(
2040+
(u): u is URL => u instanceof URL,
2041+
{ expected: "URL" }
2042+
)
2043+
2044+
console.log(String(Schema.decodeUnknownExit(URLSchema)(null)))
2045+
// Failure(Cause([Fail(SchemaError(Expected URL, got null))]))
2046+
// ^^^
2047+
// Now the error message shows "URL" instead of "<Declaration>"
2048+
```
2049+
2050+
### Adding JSON support with `toCodecJson`
2051+
2052+
`Schema.toCodecJson` derives a codec that can convert your type **to and from JSON**. By default, declared schemas have no JSON representation — encoding produces `null`:
2053+
2054+
```ts
2055+
import { Schema } from "effect"
2056+
2057+
const URLSchema = Schema.declare(
2058+
(u): u is URL => u instanceof URL,
2059+
{ expected: "URL" }
2060+
)
2061+
2062+
// Derive a JSON codec from the schema
2063+
const codec = Schema.toCodecJson(URLSchema)
2064+
2065+
// Encoding a URL produces null because Schema doesn't know
2066+
// how to serialize a URL to JSON yet
2067+
console.log(String(Schema.encodeUnknownExit(codec)(new URL("https://example.com"))))
2068+
// Success(null)
2069+
```
2070+
2071+
To fix this, provide a `toCodecJson` annotation. This annotation is a function that returns an `AST.Link`, a bridge that describes how to convert between your custom type and a JSON-friendly representation.
2072+
2073+
You build a `Link` using `Schema.link<T>()`, which takes two arguments:
2074+
2075+
1. **A JSON-side schema** — the shape of the JSON value (e.g. `Schema.String` for a URL string)
2076+
2. **A transformation** — how to convert back and forth between your type and the JSON value
2077+
2078+
**Example** (Making `URL` JSON-serializable)
2079+
2080+
```ts
2081+
import { Effect, Option, Schema, SchemaIssue, SchemaTransformation } from "effect"
2082+
2083+
const URLSchema = Schema.declare(
2084+
(u): u is URL => u instanceof URL,
2085+
{
2086+
expected: "URL",
2087+
// Teach Schema how to convert URL <-> JSON
2088+
toCodecJson: () =>
2089+
Schema.link<globalThis.URL>()(
2090+
// The JSON representation is a plain string
2091+
Schema.String,
2092+
// How to convert between URL and string
2093+
SchemaTransformation.transformOrFail<URL, string>({
2094+
// JSON string -> URL (may fail if the string is not a valid URL)
2095+
decode: (s) =>
2096+
Effect.try({
2097+
try: () => new URL(s),
2098+
catch: (e) => new SchemaIssue.InvalidValue(Option.some(s), { message: globalThis.String(e) })
2099+
}),
2100+
// URL -> JSON string (always succeeds)
2101+
encode: (url) => Effect.succeed(url.href)
2102+
})
2103+
)
2104+
}
2105+
)
2106+
2107+
const codec = Schema.toCodecJson(URLSchema)
2108+
2109+
// Now encoding produces the URL's href string
2110+
console.log(String(Schema.encodeUnknownExit(codec)(new URL("https://example.com"))))
2111+
// Success("https://example.com/")
2112+
2113+
// And decoding parses a string back into a URL
2114+
console.log(String(Schema.decodeUnknownExit(codec)("https://example.com")))
2115+
// Success(https://example.com/)
2116+
```
2117+
2118+
## `Schema.declareConstructor` (parametric types)
2119+
2120+
While `Schema.declare` works for fixed types like `URL` or `File`, some types are **generic** — they contain other types as parameters. Think of `Array<A>`, `Option<A>`, or a custom `Box<A>`. The schema for `Box<number>` is different from `Box<string>` because the inner value has a different type.
2121+
2122+
`Schema.declareConstructor` handles this by letting you define a **schema factory**: a function that takes schemas for the type parameters and returns a schema for the full type.
2123+
2124+
### How the two-step call works
2125+
2126+
`declareConstructor` uses a curried (two-step) call pattern:
2127+
2128+
```ts
2129+
Schema.declareConstructor<Type, Encoded>()(
2130+
typeParameters, // array of schemas, one per type parameter
2131+
run, // factory that produces the parsing function
2132+
annotations // optional metadata (same as Schema.declare)
2133+
)
2134+
```
2135+
2136+
1. **Outer call** `declareConstructor<Type, Encoded>()` — fixes the TypeScript types. `Type` is the decoded type, `Encoded` is the encoded type.
2137+
2. **Inner call** `(typeParameters, run, annotations)` — provides the runtime behavior:
2138+
- `typeParameters` — an array of schemas, one for each type variable (e.g. `[itemSchema]` for `Box<A>`)
2139+
- `run` — a function that receives **resolved codecs** for those type parameters and returns a **parsing function** `(input, ast, options) => Effect<T, Issue>`
2140+
- `annotations` — optional metadata like `expected`, `toCodecJson`, etc.
2141+
2142+
The parsing function you return from `run` is responsible for:
2143+
2144+
1. Checking that the input has the right shape (e.g. is an object with a `value` property)
2145+
2. Recursively decoding inner values using the provided codecs
2146+
3. Returning an `Effect` that succeeds with the decoded value or fails with an issue
2147+
2148+
**Example** (A generic `Box<A>` container)
2149+
2150+
```ts
2151+
import { Effect, Option, Schema, SchemaIssue, SchemaParser } from "effect"
2152+
2153+
// 1. Define the type
2154+
interface Box<A> {
2155+
readonly value: A
2156+
}
2157+
2158+
// 2. A type guard that checks the shape (ignoring the inner type)
2159+
const isBox = (u: unknown): u is Box<unknown> => typeof u === "object" && u !== null && "value" in u
2160+
2161+
// 3. Create a schema factory: given a schema for A, return a schema for Box<A>
2162+
const Box = <A extends Schema.Top>(item: A) =>
2163+
Schema.declareConstructor<Box<A["Type"]>, Box<A["Encoded"]>>()(
2164+
// Pass the inner schema as a type parameter
2165+
[item],
2166+
// `run` receives the resolved codec for `item`
2167+
([itemCodec]) =>
2168+
// Return the parsing function
2169+
(u, ast, options) => {
2170+
// First, check the outer shape
2171+
if (!isBox(u)) {
2172+
return Effect.fail(new SchemaIssue.InvalidType(ast, Option.some(u)))
2173+
}
2174+
// Then, decode the inner value using the item codec
2175+
return Effect.mapBothEager(
2176+
SchemaParser.decodeUnknownEffect(itemCodec)(u.value, options),
2177+
{
2178+
onSuccess: (value) => ({ value }),
2179+
// Wrap inner errors with a Pointer so the error path shows ["value"]
2180+
onFailure: (issue) => new SchemaIssue.Pointer(["value"], issue)
2181+
}
2182+
)
2183+
}
2184+
)
2185+
2186+
// Use it: Box<number> that decodes strings to finite numbers
2187+
const schema = Box(Schema.FiniteFromString)
2188+
2189+
console.log(String(Schema.decodeUnknownExit(schema)({ value: "1" })))
2190+
// Success({ value: 1 })
2191+
2192+
console.log(String(Schema.decodeUnknownExit(schema)({ value: "a" })))
2193+
// Failure(Cause([Fail(SchemaError(Expected a finite number, got NaN
2194+
// at ["value"]))]))
2195+
```
2196+
2197+
> `declareConstructor` accepts the same `annotations` as `declare` — including `expected` (for custom error messages) and `toCodecJson` (for JSON serialization). See the [`Schema.declare` section above](#schemadeclare-non-parametric-types) for details on how to use them.
2198+
19942199
# Validation
19952200

19962201
After defining a schema's shape, you can add validation rules called _filters_. Filters check runtime values against constraints like minimum length, numeric range, or custom predicates. Validation happens at runtime — Schema checks the actual value against the rules you define and reports any violations.

0 commit comments

Comments
 (0)