Skip to content

Commit a4827fa

Browse files
committed
feat(nominal-typebox): add brandedRegExp
Signed-off-by: Andres Correa Casablanca <andreu@kindspells.dev>
1 parent c5b8a9e commit a4827fa

File tree

12 files changed

+159
-53
lines changed

12 files changed

+159
-53
lines changed

@coderspirit/nominal-inputs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"devDependencies": {
3838
"@arethetypeswrong/cli": "^0.15.4",
3939
"@biomejs/biome": "1.8.3",
40-
"@types/node": "^22.1.0",
40+
"@types/node": "^22.2.0",
4141
"get-tsconfig": "^4.7.6",
4242
"publint": "^0.2.9",
4343
"rollup": "^4.20.0",

@coderspirit/nominal-symbols/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"devDependencies": {
3838
"@arethetypeswrong/cli": "^0.15.4",
3939
"@biomejs/biome": "1.8.3",
40-
"@types/node": "^22.1.0",
40+
"@types/node": "^22.2.0",
4141
"get-tsconfig": "^4.7.6",
4242
"publint": "^0.2.9",
4343
"rollup": "^4.20.0",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../.node-version

@coderspirit/nominal-typebox/README.md

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ yarn add @coderspirit/nominal-typebox
3131

3232
## Usage instructions
3333

34-
### Typebox' Type.String -> brandedString
34+
### TypeBox' Type.String -> brandedString
3535

3636
```typescript
3737
import type { FastBrand } from '@coderspirit/nominal'
@@ -61,7 +61,42 @@ const username: Username = requestObject.username // OK
6161
const corruptedUserame: Username = 'untagged string' // type error
6262
```
6363

64-
### Typebox' Type.Number -> brandedNumber
64+
### TypeBox' Type.RegExp -> brandedRegExp
65+
66+
```typescript
67+
68+
import type { FastBrand } from '@coderspirit/nominal'
69+
import { brandedRegExp } from '@coderspirit/nominal-typebox'
70+
71+
import { Object as TBObject } from '@sinclair/typebox'
72+
import { TypeCompiler } from '@sinclair/typebox/compiler'
73+
74+
type UserId = FastBrand<string, 'UserId'>
75+
76+
// Use `brandedString` instead of Typebox' `Type.String`
77+
const requestSchema = TBObject({
78+
// We can pass the same options Type.String has
79+
userId: brandedRegExp<'UserId'>(
80+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
81+
)
82+
})
83+
const requestValidator = TypeCompiler.Compile(requestSchema)
84+
85+
const requestObject = getRequestFromSomewhere() // unknown
86+
if (!requestValidator.Check(requestObject)) {
87+
throw new Error('Invalid request!')
88+
}
89+
90+
// At this point, the type checker knows that requestObject.username is
91+
// "branded" as 'Username'
92+
93+
const userId: UserId = requestObject.userId // OK
94+
const corruptedUserId: UserId = 'untagged (and probably wrong) id' // type error
95+
```
96+
97+
---
98+
99+
### TypeBox' Type.Number -> brandedNumber
65100

66101

67102
```typescript
@@ -93,12 +128,14 @@ const corruptedLat: Latitude = 10 // type error
93128
const corruptedLon: Longitude = 10 // type error
94129
```
95130

96-
### Typebox' Type.Integer -> brandedInteger
131+
### TypeBox' Type.Integer -> brandedInteger
97132

98133
The same applies as for the two previous examples, you can use `brandedInteger`
99134
instead of Typebox' `Type.Integer`.
100135

101-
### Typebox' Type.Array -> brandedArray
136+
---
137+
138+
### TypeBox' Type.Array -> brandedArray
102139

103140
`brandedArray` has the same signature as Typebox' `Type.Array`, except that we
104141
have to pass a "brand" string argument as its first parameter:
@@ -115,7 +152,7 @@ const arraySchema = brandedArray(
115152
)
116153
```
117154

118-
### Typebox' Type.Object -> brandedObject
155+
### TypeBox' Type.Object -> brandedObject
119156

120157
`brandedObject` has the same signature as Typebox' `Type.Object`, except that we
121158
have to pass a "brand" string argument as its first parameter:
@@ -134,7 +171,7 @@ const objectSchema = brandedObject(
134171
)
135172
```
136173

137-
### Typebox' Type.Union -> brandedUnion
174+
### TypeBox' Type.Union -> brandedUnion
138175

139176
`brandedUnion` has the same signature as Typebox' `Type.Union`, except that we
140177
have to pass a "brand" string argument as its first parameter:
@@ -149,6 +186,8 @@ const unionSchema = brandedUnion(
149186
)
150187
```
151188

189+
---
190+
152191
### Fallback alternative
153192

154193
In case this library does not provide a specific schema factory for your type,

@coderspirit/nominal-typebox/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@coderspirit/nominal-typebox",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"description": "Integration of @coderspirit/nominal with @sinclair/typebox",
55
"main": "./dist/main.cjs",
66
"module": "./dist/main.mjs",
@@ -48,8 +48,8 @@
4848
"@arethetypeswrong/cli": "^0.15.4",
4949
"@biomejs/biome": "1.8.3",
5050
"@coderspirit/nominal-inputs": "workspace:^",
51-
"@sinclair/typebox": "^0.33.3",
52-
"@types/node": "^22.1.0",
51+
"@sinclair/typebox": "^0.33.4",
52+
"@types/node": "^22.2.0",
5353
"@vitest/coverage-v8": "^2.0.5",
5454
"get-tsconfig": "^4.7.6",
5555
"publint": "^0.2.9",

@coderspirit/nominal-typebox/src/main.mts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type { BrandedSchema } from './schema.mts'
44
// Basic types
55
export type { BrandedIntegerSchema, BrandedNumberSchema } from './number.mts'
66
export type { BrandedStringSchema } from './string.mts'
7+
export type { BrandedRegExpSchema } from './regexp.mts'
78

89
// Complex types
910
export type { BrandedArraySchema } from './array.mts'
@@ -16,6 +17,7 @@ export { brandedSchema } from './schema.mts'
1617
// Basic schemas
1718
export { brandedInteger, brandedNumber } from './number.mts'
1819
export { brandedString } from './string.mts'
20+
export { brandedRegExp } from './regexp.mts'
1921

2022
// Complex schemas
2123
export { brandedArray } from './array.mts'
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { FastBrand } from '@coderspirit/nominal'
2+
import type { Kind, RegExpOptions, TSchema } from '@sinclair/typebox'
3+
import { RegExp as TBRegExp } from '@sinclair/typebox'
4+
5+
export interface BrandedRegExpSchema<B extends string> extends TSchema {
6+
// Copied from TRegExp
7+
[Kind]: 'RegExp'
8+
type: 'RegExp'
9+
source: string
10+
flags: string
11+
12+
// Our sauce
13+
static: FastBrand<string, B>
14+
}
15+
16+
function brandedRegExp<const B extends string>(
17+
regex: RegExp,
18+
options?: RegExpOptions,
19+
): BrandedRegExpSchema<B>
20+
function brandedRegExp<const B extends string>(
21+
regex: string,
22+
options?: RegExpOptions,
23+
): BrandedRegExpSchema<B>
24+
function brandedRegExp<const B extends string>(
25+
pattern: string | RegExp,
26+
options?: RegExpOptions,
27+
): BrandedRegExpSchema<B> {
28+
return TBRegExp(pattern as string, options) as BrandedRegExpSchema<B>
29+
}
30+
31+
export { brandedRegExp }
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import assert from 'node:assert/strict'
2+
3+
import type { FastBrand } from '@coderspirit/nominal'
4+
import { TypeCompiler } from '@sinclair/typebox/compiler'
5+
import { describe, expect, it } from 'vitest'
6+
7+
import { brandedRegExp } from '../main.mjs'
8+
9+
describe('brandedRegExp', () => {
10+
it('lets typebox to annotate a regexp with a brand', () => {
11+
const regexpSchema = brandedRegExp<'UUID'>(
12+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
13+
)
14+
const regexpValidator = TypeCompiler.Compile(regexpSchema)
15+
16+
const valueToCheck = '09e77e4a-175d-46a1-b6c6-ff960c30193b'
17+
if (!regexpValidator.Check(valueToCheck)) {
18+
throw new assert.AssertionError({ message: 'validation should pass' })
19+
}
20+
21+
const regexpSink: FastBrand<string, 'UUID'> = valueToCheck
22+
expect(regexpSink).toBe('09e77e4a-175d-46a1-b6c6-ff960c30193b')
23+
24+
// We perform the following useless assignments to show the contrast between
25+
// tagged values and untagged values.
26+
27+
// @ts-expect-error
28+
const corruptedRegexpSink: FastBrand<string, 'UUID'> =
29+
'09e77e4a-175d-46a1-b6c6-ff960c30193b'
30+
expect(corruptedRegexpSink).toBe('09e77e4a-175d-46a1-b6c6-ff960c30193b')
31+
})
32+
})

@coderspirit/nominal/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"devDependencies": {
4040
"@arethetypeswrong/cli": "^0.15.4",
4141
"@biomejs/biome": "1.8.3",
42-
"@types/node": "^22.1.0",
42+
"@types/node": "^22.2.0",
4343
"@vitest/coverage-v8": "^2.0.5",
4444
"get-tsconfig": "^4.7.6",
4545
"publint": "^0.2.9",

@coderspirit/nominal/rollup.config.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const tsconfig = getTsconfig(projectDir)
1111
const target = tsconfig?.config.compilerOptions?.target ?? 'es2020'
1212

1313
const input = 'src/main.mts'
14+
const external = ['@coderspirit/nominal-symbols']
1415

1516
export default defineConfig([
1617
{
@@ -26,7 +27,7 @@ export default defineConfig([
2627
minify: true,
2728
}),
2829
],
29-
external: ['@coderspirit/nominal-symbols'],
30+
external,
3031
},
3132
{
3233
input,
@@ -35,6 +36,6 @@ export default defineConfig([
3536
{ format: 'esm', file: 'dist/main.d.mts' },
3637
],
3738
plugins: [dts()],
38-
external: ['@coderspirit/nominal-symbols'],
39+
external,
3940
},
4041
])

0 commit comments

Comments
 (0)