Skip to content

Commit 731ffb2

Browse files
committed
Add S.brand
1 parent 678df8e commit 731ffb2

File tree

7 files changed

+96
-3
lines changed

7 files changed

+96
-3
lines changed

IDEAS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## Alpha.4
44

55
- Use built-in JSON String transformation for JSON String output in `S.compile`
6+
- Fix https://github.com/DZakh/sury/issues/150
7+
- Add `S.brand` for TS API
68

79
## v11
810

docs/js-usage.md

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- [JSON Schema](#json-schema)
1515
- [Standard Schema](#standard-schema)
1616
- [Defining schemas](#defining-schemas)
17+
- [Advanced schemas](#advanced-schemas)
1718
- [Strings](#strings)
1819
- [ISO datetimes](#iso-datetimes)
1920
- [Numbers](#numbers)
@@ -368,20 +369,40 @@ S.any;
368369
// Never type
369370
// Allows no values
370371
S.never;
372+
```
373+
374+
### Advanced schemas
375+
376+
The goal of **Sury** is to provide the best DX. To achieve that, everything is a schema — use it directly without a `()` call. However, some schemas are opt‑in to keep bundle size small, so you must enable them explicitly. This also helps prevent your team from using the wrong API.
377+
378+
Enable the schemas you need at the project root:
379+
380+
```ts
381+
S.enableJson();
382+
S.enableJsonString();
383+
```
371384

385+
And use them as usual:
386+
387+
> 🧠 Don't forget `S.to` which comes with powerful coercion logic.
388+
389+
```ts
372390
// JSON type
373-
S.enableJson(); // ❕ Call at the project root.
374391
// Allows string | boolean | number | null | Record<string, JSON> | JSON[]
375392
S.json;
376393

377394
// JSON string
378-
S.enableJsonString(); // ❕ Call at the project root.
395+
379396
// Asserts that the input is a valid JSON string
380397
S.jsonString;
381398
S.jsonStringWithSpace(2);
399+
382400
// Parses JSON string and validates that it's a number
401+
// JSON string -> number
383402
S.jsonString.with(S.to, S.number);
403+
384404
// Serializes number to JSON string
405+
// number -> JSON string
385406
S.number.with(S.to, S.jsonString);
386407
```
387408

@@ -867,6 +888,41 @@ S.toJSONSchema(documentedStringSchema);
867888
// }
868889
```
869890

891+
## Brand
892+
893+
Add a type-only symbol to an existing type so that only values produced by validation satisfy it.
894+
895+
Use `S.brand` to attach a nominal brand to a schema's output. This is a TypeScript-only marker: it does not change runtime behavior. Combine it with `S.refine` (or any validation) so only validated values can acquire the brand.
896+
897+
```ts
898+
// Brand a string as a UserId
899+
const UserId = S.string.with(S.brand, "UserId");
900+
type UserId = S.Infer<typeof UserId>; // S.Brand<string, "UserId">
901+
902+
const id: UserId = S.parseOrThrow("u_123", UserId); // OK
903+
const asString: string = id; // OK: branded value is assignable to string
904+
// @ts-expect-error - A plain string is not assignable to a branded string
905+
const notId: UserId = "u_123";
906+
```
907+
908+
You can define brands for refined constraints, like even numbers:
909+
910+
```ts
911+
const even = S.number
912+
.with(S.refine, (value, s) => {
913+
if (value % 2 !== 0) s.fail("Expected an even number");
914+
})
915+
.with(S.brand, "even");
916+
917+
type Even = S.Infer<typeof even>; // S.Brand<number, "even">
918+
919+
const good: Even = S.parseOrThrow(2, even); // OK
920+
// @ts-expect-error - number is not assignable to brand "even"
921+
const bad: Even = 5;
922+
```
923+
924+
For more information on branding in general, check out [this excellent article](https://www.learningtypescript.com/articles/branded-types) from [Josh Goldberg](https://github.com/joshuakgoldberg).
925+
870926
## Custom schema
871927

872928
**Sury** might not have many built-in schemas for your use case. In this case you can create a custom schema for any TypeScript type.

packages/sury/scripts/pack/Pack.res

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ let filesMapping = [
182182
("enableJson", "S.enableJson"),
183183
("enableJsonString", "S.enableJsonString"),
184184
("global", "S.global"),
185+
("brand", "s => s"),
185186
]
186187

187188
sourePaths->Array.forEach(path => {

packages/sury/scripts/pack/Pack.res.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,10 @@ let filesMapping = [
332332
[
333333
"global",
334334
"S.global"
335+
],
336+
[
337+
"brand",
338+
"s => s"
335339
]
336340
];
337341

packages/sury/src/S.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ export type Schema<Output, Input = unknown> = {
118118
): Schema<Output, Input>;
119119
// with(message: string): t<Output, Input>; TODO: implement
120120
with<O, I>(fn: (schema: Schema<Output, Input>) => Schema<O, I>): Schema<O, I>;
121+
with<O, I, A1 extends string>(
122+
fn: (schema: Schema<Output, Input>, arg1: A1) => Schema<O, I>,
123+
arg1: A1
124+
): Schema<O, I>;
121125
with<O, I, A1>(
122126
fn: (schema: Schema<Output, Input>, arg1: A1) => Schema<O, I>,
123127
arg1: A1
@@ -298,6 +302,19 @@ export type UnknownToInput<T> = T extends Schema<unknown, infer Input>
298302
>
299303
: T;
300304

305+
export type Brand<T, ID extends string> = T & {
306+
/**
307+
* TypeScript won't suggest strings beginning with a space as properties.
308+
* Useful for symbol-like string properties.
309+
*/
310+
readonly [" brand"]: [T, ID];
311+
};
312+
313+
export function brand<ID extends string, Output = unknown, Input = unknown>(
314+
schema: Schema<Output, Input>,
315+
brandId: ID
316+
): Schema<Brand<Output, ID>, Input>;
317+
301318
// Grok told that it makes things faster
302319
// TODO: Verify it with ArkType test framework
303320
type HasUndefined<T> = [T] extends [undefined]

packages/sury/src/S.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,5 @@ export var datetime = S.datetime
7070
export var trim = S.trim
7171
export var enableJson = S.enableJson
7272
export var enableJsonString = S.enableJsonString
73-
export var global = S.global
73+
export var global = S.global
74+
export var brand = s => s

packages/sury/tests/S_test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2600,6 +2600,18 @@ test("Example of transformed schema", (t) => {
26002600
}
26012601
});
26022602

2603+
test("Brand", (t) => {
2604+
const schema = S.string.with(S.brand, "Foo");
2605+
type Foo = S.Infer<typeof schema>;
2606+
expectType<SchemaEqual<typeof schema, S.Brand<string, "Foo">, string>>(true);
2607+
const result = S.parseOrThrow("hello", schema);
2608+
expectType<S.Brand<string, "Foo">>(result);
2609+
t.deepEqual(result, "hello");
2610+
2611+
// @ts-expect-error - Branded string is not assignable to string
2612+
const a: Foo = "bar";
2613+
});
2614+
26032615
test("fromJSONSchema", (t) => {
26042616
const emailSchema = S.fromJSONSchema<string>({
26052617
type: "string",

0 commit comments

Comments
 (0)