snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
888
packages/validate/DOCS.md
Normal file
888
packages/validate/DOCS.md
Normal file
@@ -0,0 +1,888 @@
|
||||
# @tldraw/validate Documentation
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
### What is @tldraw/validate?
|
||||
|
||||
@tldraw/validate is a TypeScript library for runtime validation that combines type safety with performance optimization. It provides validators for data structures ranging from simple primitives to complex nested objects, with detailed error reporting and composable validation logic.
|
||||
|
||||
This library provides the validation foundation for the tldraw SDK, ensuring type safety and data integrity across shape definitions, store operations, and external data handling.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install @tldraw/validate
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
@tldraw/validate is written in TypeScript and provides type safety out of the box. All validators maintain full type information, ensuring your validated data has the correct types at both compile time and runtime.
|
||||
|
||||
### Quick Example
|
||||
|
||||
Here's a simple example showing the core validation concepts:
|
||||
|
||||
```ts
|
||||
import { T } from '@tldraw/validate'
|
||||
|
||||
// Create some validators
|
||||
const userValidator = T.object({
|
||||
name: T.string,
|
||||
age: T.positiveInteger,
|
||||
email: T.linkUrl.optional(),
|
||||
})
|
||||
|
||||
// Validate data safely
|
||||
try {
|
||||
const user = userValidator.validate({
|
||||
name: 'Alice',
|
||||
age: 25,
|
||||
email: 'alice@example.com',
|
||||
})
|
||||
// user is now typed as { name: string; age: number; email?: string }
|
||||
console.log(`Hello ${user.name}!`)
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error.message)
|
||||
}
|
||||
```
|
||||
|
||||
In just a few lines, you've created type-safe validation that catches errors at runtime and provides detailed feedback when validation fails.
|
||||
|
||||
## 2. Core Concepts
|
||||
|
||||
### The Validator Class
|
||||
|
||||
A **Validator** is the fundamental building block of the validation system. It's a container that holds validation logic and provides methods for safely checking and converting unknown data into typed values.
|
||||
|
||||
```ts
|
||||
import { T } from '@tldraw/validate'
|
||||
|
||||
const numberValidator = T.number
|
||||
const result = numberValidator.validate(42) // Returns 42 as number
|
||||
```
|
||||
|
||||
Every validator implements the same core interface, allowing them to be composed and chained together in powerful ways.
|
||||
|
||||
#### Basic Validation Methods
|
||||
|
||||
All validators provide these essential methods:
|
||||
|
||||
**validate(value)** - Validates a value and returns it with correct typing, or throws an error:
|
||||
|
||||
```ts
|
||||
const validated = T.string.validate('hello') // Returns "hello" as string
|
||||
// T.string.validate(123) // Throws ValidationError
|
||||
```
|
||||
|
||||
**isValid(value)** - Checks if a value is valid without throwing:
|
||||
|
||||
```ts
|
||||
if (T.number.isValid(someValue)) {
|
||||
// someValue is now typed as number within this block
|
||||
console.log(someValue + 1)
|
||||
}
|
||||
```
|
||||
|
||||
**validateUsingKnownGoodVersion(knownGood, newValue)** - Performance-optimized validation:
|
||||
|
||||
```ts
|
||||
// If newValue hasn't changed, returns knownGood without re-validation
|
||||
const optimized = validator.validateUsingKnownGoodVersion(previousValue, newValue)
|
||||
```
|
||||
|
||||
> Tip: `validateUsingKnownGoodVersion` is a powerful performance optimization that avoids re-validating unchanged data, especially useful for large objects and frequent validation calls.
|
||||
|
||||
### Validation Errors
|
||||
|
||||
When validation fails, @tldraw/validate provides detailed error information through the **ValidationError** class:
|
||||
|
||||
```ts
|
||||
try {
|
||||
T.positiveInteger.validate(-5)
|
||||
} catch (error) {
|
||||
console.log(error.message) // "Expected a positive integer, got -5"
|
||||
console.log(error.path) // Array showing location in nested structures
|
||||
}
|
||||
```
|
||||
|
||||
For complex nested structures, errors include precise path information:
|
||||
|
||||
```ts
|
||||
const complexValidator = T.object({
|
||||
users: T.arrayOf(
|
||||
T.object({
|
||||
name: T.string,
|
||||
settings: T.object({
|
||||
theme: T.literalEnum('light', 'dark'),
|
||||
}),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
// Error: "At users.0.settings.theme: Expected 'light' or 'dark', got 'blue'"
|
||||
```
|
||||
|
||||
## 3. Primitive Validators
|
||||
|
||||
### Basic Types
|
||||
|
||||
@tldraw/validate provides validators for all TypeScript primitive types:
|
||||
|
||||
```ts
|
||||
import { T } from '@tldraw/validate'
|
||||
|
||||
// Core types
|
||||
const name = T.string.validate('Alice')
|
||||
const count = T.number.validate(42)
|
||||
const isActive = T.boolean.validate(true)
|
||||
const largeNumber = T.bigint.validate(123n)
|
||||
|
||||
// Special types
|
||||
const anything = T.unknown.validate({ any: 'value' })
|
||||
const untyped = T.any.validate(someValue) // Escape hatch for any type
|
||||
```
|
||||
|
||||
**Arrays** can be validated with or without content validation:
|
||||
|
||||
```ts
|
||||
// Any array
|
||||
const items = T.array.validate([1, 'hello', true])
|
||||
|
||||
// Array with validated contents
|
||||
const numbers = T.arrayOf(T.number).validate([1, 2, 3])
|
||||
```
|
||||
|
||||
### Number Validators
|
||||
|
||||
Numbers have specialized validators for common validation needs:
|
||||
|
||||
```ts
|
||||
// Basic number validation (finite, non-NaN)
|
||||
const temperature = T.number.validate(23.5)
|
||||
|
||||
// Non-negative numbers (>= 0)
|
||||
const price = T.positiveNumber.validate(29.99)
|
||||
|
||||
// Positive numbers (> 0)
|
||||
const quantity = T.nonZeroNumber.validate(0.01)
|
||||
|
||||
// Integers
|
||||
const wholeNumber = T.integer.validate(42)
|
||||
|
||||
// Non-negative integers (>= 0)
|
||||
const positiveCount = T.positiveInteger.validate(5)
|
||||
|
||||
// Positive integers (> 0)
|
||||
const itemCount = T.nonZeroInteger.validate(1)
|
||||
```
|
||||
|
||||
> Note: `positiveNumber` and `positiveInteger` validate for non-negative values (`>= 0`), while `nonZeroNumber` and `nonZeroInteger` validate for positive values (`> 0`).
|
||||
|
||||
These validators catch common data issues:
|
||||
|
||||
```ts
|
||||
T.number.validate(NaN) // Throws: "Expected a finite number, got NaN"
|
||||
T.positiveNumber.validate(-1) // Throws: "Expected a positive number, got -1"
|
||||
T.nonZeroNumber.validate(0) // Throws: "Expected a non-zero positive number, got 0"
|
||||
T.integer.validate(3.14) // Throws: "Expected an integer, got 3.14"
|
||||
```
|
||||
|
||||
### URL Validators
|
||||
|
||||
URLs require careful validation for security. @tldraw/validate provides context-specific URL validators:
|
||||
|
||||
```ts
|
||||
// Safe for user-facing links (http, https, mailto)
|
||||
const blogLink = T.linkUrl.validate('https://example.com')
|
||||
const contactEmail = T.linkUrl.validate('mailto:hello@example.com')
|
||||
|
||||
// Safe for resource loading (http, https, data URLs, asset URLs)
|
||||
const imageSource = T.srcUrl.validate('data:image/png;base64,...')
|
||||
const staticAsset = T.srcUrl.validate('https://cdn.example.com/image.jpg')
|
||||
|
||||
// Strict HTTP/HTTPS only
|
||||
const apiEndpoint = T.httpUrl.validate('https://api.example.com')
|
||||
```
|
||||
|
||||
> Tip: Always use the most restrictive URL validator for your use case. `linkUrl` prevents XSS in user-generated links, while `srcUrl` allows data URLs for embedded content.
|
||||
|
||||
### Literal and Enum Validators
|
||||
|
||||
For validating specific values or sets of values:
|
||||
|
||||
```ts
|
||||
// Single literal value
|
||||
const appName = T.literal('tldraw').validate('tldraw')
|
||||
|
||||
// Multiple allowed values
|
||||
const theme = T.literalEnum('light', 'dark', 'auto').validate('dark')
|
||||
|
||||
// Using a Set for enum values
|
||||
const allowedMethods = new Set(['GET', 'POST', 'PUT'] as const)
|
||||
const method = T.setEnum(allowedMethods).validate('GET')
|
||||
```
|
||||
|
||||
## 4. Complex Validators
|
||||
|
||||
### Object Validation
|
||||
|
||||
The **object validator** handles structured data with type-safe property validation:
|
||||
|
||||
```ts
|
||||
// Define the shape of your data
|
||||
const userValidator = T.object({
|
||||
id: T.string,
|
||||
name: T.string,
|
||||
age: T.positiveInteger,
|
||||
isAdmin: T.boolean,
|
||||
lastLogin: T.string.optional(),
|
||||
})
|
||||
|
||||
// Validate objects
|
||||
const user = userValidator.validate({
|
||||
id: 'user123',
|
||||
name: 'Bob',
|
||||
age: 30,
|
||||
isAdmin: false,
|
||||
// lastLogin is optional, can be omitted
|
||||
})
|
||||
// user is typed as: { id: string; name: string; age: number; isAdmin: boolean; lastLogin?: string }
|
||||
```
|
||||
|
||||
#### Object Options
|
||||
|
||||
Objects are strict by default but can be configured:
|
||||
|
||||
```ts
|
||||
// Allow extra properties (they'll be preserved but not typed)
|
||||
const flexibleUser = T.object({
|
||||
name: T.string,
|
||||
age: T.number,
|
||||
}).allowUnknownProperties()
|
||||
|
||||
flexibleUser.validate({
|
||||
name: 'Alice',
|
||||
age: 25,
|
||||
favoriteColor: 'blue', // This won't cause an error
|
||||
})
|
||||
```
|
||||
|
||||
#### Extending Objects
|
||||
|
||||
Create new validators by extending existing ones:
|
||||
|
||||
```ts
|
||||
const baseUser = T.object({
|
||||
name: T.string,
|
||||
age: T.number,
|
||||
})
|
||||
|
||||
const adminUser = baseUser.extend({
|
||||
permissions: T.arrayOf(T.string),
|
||||
lastLogin: T.string,
|
||||
})
|
||||
// adminUser validates: { name: string; age: number; permissions: string[]; lastLogin: string }
|
||||
```
|
||||
|
||||
### Array Validation
|
||||
|
||||
Arrays can be validated with content constraints:
|
||||
|
||||
```ts
|
||||
// Array of specific type
|
||||
const numbers = T.arrayOf(T.number)
|
||||
const validNumbers = numbers.validate([1, 2, 3.14, -5])
|
||||
|
||||
// Array with constraints
|
||||
const nonEmptyNames = T.arrayOf(T.string).nonEmpty()
|
||||
const atLeastTwo = T.arrayOf(T.string).lengthGreaterThan1()
|
||||
|
||||
// Nested arrays
|
||||
const matrix = T.arrayOf(T.arrayOf(T.number))
|
||||
const grid = matrix.validate([
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
])
|
||||
```
|
||||
|
||||
### Dictionary Validation
|
||||
|
||||
For objects used as key-value maps:
|
||||
|
||||
```ts
|
||||
// String keys to number values
|
||||
const scores = T.dict(T.string, T.number)
|
||||
const gameScores = scores.validate({
|
||||
alice: 100,
|
||||
bob: 85,
|
||||
charlie: 92,
|
||||
})
|
||||
|
||||
// Complex value types
|
||||
const userPreferences = T.dict(
|
||||
T.string,
|
||||
T.object({
|
||||
theme: T.literalEnum('light', 'dark'),
|
||||
notifications: T.boolean,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
### Union Validation
|
||||
|
||||
For discriminated unions (objects that can be one of several types):
|
||||
|
||||
```ts
|
||||
// Shape definitions with discriminated unions
|
||||
const shapeValidator = T.union('type', {
|
||||
rectangle: T.object({
|
||||
type: T.literal('rectangle'),
|
||||
width: T.positiveNumber,
|
||||
height: T.positiveNumber,
|
||||
}),
|
||||
circle: T.object({
|
||||
type: T.literal('circle'),
|
||||
radius: T.positiveNumber,
|
||||
}),
|
||||
triangle: T.object({
|
||||
type: T.literal('triangle'),
|
||||
base: T.positiveNumber,
|
||||
height: T.positiveNumber,
|
||||
}),
|
||||
})
|
||||
|
||||
// Validates based on the discriminator field
|
||||
const rectangle = shapeValidator.validate({
|
||||
type: 'rectangle',
|
||||
width: 100,
|
||||
height: 50,
|
||||
})
|
||||
// rectangle is typed as: { type: 'rectangle'; width: number; height: number }
|
||||
```
|
||||
|
||||
## 5. Composing Validators
|
||||
|
||||
### Nullable and Optional Values
|
||||
|
||||
Transform validators to accept null or undefined:
|
||||
|
||||
```ts
|
||||
const optionalString = T.string.optional() // string | undefined
|
||||
const nullableNumber = T.number.nullable() // number | null
|
||||
|
||||
// Can also use helper functions
|
||||
const maybeUser = T.optional(userValidator) // User | undefined
|
||||
const userOrNull = T.nullable(userValidator) // User | null
|
||||
```
|
||||
|
||||
### Refinement and Checks
|
||||
|
||||
Add additional validation logic to existing validators:
|
||||
|
||||
#### Using refine() for Transformations
|
||||
|
||||
**refine()** validates and potentially transforms the value:
|
||||
|
||||
```ts
|
||||
// Transform string to uppercase
|
||||
const upperCaseString = T.string.refine((value) => {
|
||||
return value.toUpperCase()
|
||||
})
|
||||
|
||||
const result = upperCaseString.validate('hello') // Returns "HELLO"
|
||||
|
||||
// Validate and parse JSON
|
||||
const jsonValidator = T.string.refine((str) => {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch (error) {
|
||||
throw new Error('Invalid JSON')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### Using check() for Additional Constraints
|
||||
|
||||
**check()** adds validation without changing the value. It has two forms:
|
||||
|
||||
`check(checkFn)`:
|
||||
|
||||
```ts
|
||||
const evenNumber = T.number.check((value) => {
|
||||
if (value % 2 !== 0) {
|
||||
throw new Error('Number must be even')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
`check(name, checkFn)`:
|
||||
|
||||
You can also provide a name for the check, which will be included in error messages for easier debugging.
|
||||
|
||||
```ts
|
||||
const strongPassword = T.string
|
||||
.check('min-length', (password) => {
|
||||
if (password.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters')
|
||||
}
|
||||
})
|
||||
.check('uppercase', (password) => {
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
throw new Error('Password must contain an uppercase letter')
|
||||
}
|
||||
})
|
||||
.check('number', (password) => {
|
||||
if (!/[0-9]/.test(password)) {
|
||||
throw new Error('Password must contain a number')
|
||||
}
|
||||
})
|
||||
|
||||
// Example error:
|
||||
// "At (check uppercase): Password must contain an uppercase letter"
|
||||
```
|
||||
|
||||
### Union Types
|
||||
|
||||
Combine multiple validators with **or()**:
|
||||
|
||||
```ts
|
||||
const stringOrNumber = T.or(T.string, T.number)
|
||||
const value1 = stringOrNumber.validate('hello') // string
|
||||
const value2 = stringOrNumber.validate(42) // number
|
||||
|
||||
// More complex unions
|
||||
const idValidator = T.or(
|
||||
T.string, // UUID string
|
||||
T.positiveInteger // Numeric ID
|
||||
)
|
||||
```
|
||||
|
||||
## 6. Advanced Features
|
||||
|
||||
### Performance Optimization with Known Good Values
|
||||
|
||||
The validation system includes powerful performance optimizations for repeated validation:
|
||||
|
||||
```ts
|
||||
// First validation - full validation occurs
|
||||
const user = userValidator.validate(userData)
|
||||
|
||||
// Later validation with mostly unchanged data
|
||||
const updatedUser = userValidator.validateUsingKnownGoodVersion(
|
||||
user, // Previous valid value
|
||||
newUserData // New data to validate
|
||||
)
|
||||
|
||||
// If newUserData is identical to user, returns user immediately
|
||||
// If partially changed, only validates the changed parts
|
||||
// If completely different, performs full validation
|
||||
```
|
||||
|
||||
This is particularly powerful for complex objects:
|
||||
|
||||
```ts
|
||||
const complexObject = T.object({
|
||||
metadata: T.object({
|
||||
created: T.string,
|
||||
updated: T.string,
|
||||
}),
|
||||
content: T.arrayOf(
|
||||
T.object({
|
||||
id: T.string,
|
||||
text: T.string,
|
||||
tags: T.arrayOf(T.string),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
// Only validates changed portions of the structure
|
||||
const optimized = complexObject.validateUsingKnownGoodVersion(previouslyValidated, incomingData)
|
||||
```
|
||||
|
||||
### Model Validation
|
||||
|
||||
For named entities with enhanced debugging:
|
||||
|
||||
```ts
|
||||
// Define a model with a name for better error reporting
|
||||
const userModel = T.model(
|
||||
'User',
|
||||
T.object({
|
||||
id: T.string,
|
||||
name: T.string,
|
||||
email: T.linkUrl,
|
||||
})
|
||||
)
|
||||
|
||||
// Errors will include the model name for clarity:
|
||||
// "At User.email: Expected a valid URL, got 'invalid-email'"
|
||||
```
|
||||
|
||||
### JSON Value Validation
|
||||
|
||||
Safe handling of JSON data:
|
||||
|
||||
```ts
|
||||
// Validates any valid JSON value (primitives, arrays, objects)
|
||||
const jsonData = T.jsonValue.validate(someUnknownData)
|
||||
|
||||
// JSON object specifically
|
||||
const config = T.jsonDict().validate({
|
||||
setting1: 'value1',
|
||||
setting2: 42,
|
||||
setting3: ['a', 'b', 'c'],
|
||||
})
|
||||
```
|
||||
|
||||
## 7. Debugging and Error Handling
|
||||
|
||||
### Understanding Validation Errors
|
||||
|
||||
@tldraw/validate provides rich error information to help debug validation issues:
|
||||
|
||||
```ts
|
||||
const nestedValidator = T.object({
|
||||
company: T.object({
|
||||
employees: T.arrayOf(
|
||||
T.object({
|
||||
name: T.string,
|
||||
contact: T.object({
|
||||
email: T.linkUrl,
|
||||
phone: T.string.optional(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
try {
|
||||
nestedValidator.validate({
|
||||
company: {
|
||||
employees: [
|
||||
{
|
||||
name: 'Alice',
|
||||
contact: {
|
||||
email: 'not-an-email',
|
||||
phone: '555-1234',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(error.message)
|
||||
// "At company.employees.0.contact.email: Expected a valid url, got 'not-an-email'"
|
||||
|
||||
console.log(error.path)
|
||||
// ['company', 'employees', 0, 'contact', 'email']
|
||||
|
||||
console.log(error.rawMessage)
|
||||
// "Expected a valid url, got 'not-an-email'"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Path Navigation
|
||||
|
||||
Error paths help you locate exactly where validation failed:
|
||||
|
||||
- **Property names** appear as strings: `"users"`
|
||||
- **Array indices** appear as numbers: `0`, `1`, `2`
|
||||
- **Union discriminators** appear with context: `"(type = rectangle)"`
|
||||
|
||||
```ts
|
||||
// Example paths:
|
||||
'name' // Simple property
|
||||
'users.0' // First item in users array
|
||||
'shape.(type = circle).radius' // radius property of circle variant
|
||||
'config.database.connections.0.host' // Deep nesting
|
||||
```
|
||||
|
||||
### Common Validation Patterns
|
||||
|
||||
#### Validating External APIs
|
||||
|
||||
```ts
|
||||
const apiResponseValidator = T.object({
|
||||
status: T.literalEnum('success', 'error'),
|
||||
data: T.object({
|
||||
users: T.arrayOf(
|
||||
T.object({
|
||||
id: T.string,
|
||||
name: T.string,
|
||||
email: T.linkUrl.optional(),
|
||||
})
|
||||
),
|
||||
}).optional(),
|
||||
error: T.string.optional(),
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users')
|
||||
const json = await response.json()
|
||||
const validatedResponse = apiResponseValidator.validate(json)
|
||||
|
||||
if (validatedResponse.status === 'success') {
|
||||
// TypeScript knows data is defined here
|
||||
console.log(`Found ${validatedResponse.data.users.length} users`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API response validation failed:', error.message)
|
||||
}
|
||||
```
|
||||
|
||||
#### Validating User Input
|
||||
|
||||
```ts
|
||||
const userInputValidator = T.object({
|
||||
title: T.string.check((title) => {
|
||||
if (title.length < 3) {
|
||||
throw new Error('Title must be at least 3 characters')
|
||||
}
|
||||
if (title.length > 100) {
|
||||
throw new Error('Title cannot exceed 100 characters')
|
||||
}
|
||||
}),
|
||||
category: T.literalEnum('work', 'personal', 'hobby'),
|
||||
priority: T.integer.check((priority) => {
|
||||
if (priority < 1 || priority > 5) {
|
||||
throw new Error('Priority must be between 1 and 5')
|
||||
}
|
||||
}),
|
||||
tags: T.arrayOf(T.string).optional(),
|
||||
})
|
||||
|
||||
// Safe handling of form data
|
||||
function processUserInput(formData: unknown) {
|
||||
try {
|
||||
const validInput = userInputValidator.validate(formData)
|
||||
// Process with confidence that data is valid
|
||||
return saveToDatabase(validInput)
|
||||
} catch (error) {
|
||||
// Return user-friendly validation messages
|
||||
return { error: error.message }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Migration and Schema Validation
|
||||
|
||||
```ts
|
||||
// Validate data during migrations
|
||||
const migrationValidator = T.object({
|
||||
version: T.positiveInteger,
|
||||
data: T.jsonValue,
|
||||
timestamp: T.string,
|
||||
})
|
||||
|
||||
const legacyUserValidator = T.object({
|
||||
name: T.string,
|
||||
email: T.string, // Old schema: any string
|
||||
age: T.number.optional(),
|
||||
})
|
||||
|
||||
const modernUserValidator = T.object({
|
||||
name: T.string,
|
||||
email: T.linkUrl, // New schema: validated URL
|
||||
age: T.positiveInteger.optional(),
|
||||
createdAt: T.string,
|
||||
})
|
||||
|
||||
function migrateUser(legacyData: unknown) {
|
||||
// Validate old format
|
||||
const legacy = legacyUserValidator.validate(legacyData)
|
||||
|
||||
// Transform to new format with additional validation
|
||||
const modern = modernUserValidator.validate({
|
||||
name: legacy.name,
|
||||
email: legacy.email, // This will now validate as URL
|
||||
age: legacy.age,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return modern
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Integration with tldraw
|
||||
|
||||
### Shape Definition Validation
|
||||
|
||||
@tldraw/validate is extensively used throughout tldraw for shape validation:
|
||||
|
||||
```ts
|
||||
// Example from TLImageShape
|
||||
const imageShapeProps = T.object({
|
||||
w: T.nonZeroNumber,
|
||||
h: T.nonZeroNumber,
|
||||
playing: T.boolean,
|
||||
url: T.linkUrl,
|
||||
assetId: T.string.nullable(),
|
||||
crop: T.object({
|
||||
topLeft: T.object({ x: T.number, y: T.number }),
|
||||
bottomRight: T.object({ x: T.number, y: T.number }),
|
||||
}).nullable(),
|
||||
flipX: T.boolean,
|
||||
flipY: T.boolean,
|
||||
})
|
||||
```
|
||||
|
||||
### Store Integration
|
||||
|
||||
The validation system integrates with tldraw's reactive store:
|
||||
|
||||
```ts
|
||||
// Validating records during store operations
|
||||
const shapeRecord = shapeValidator.validate(incomingShapeData)
|
||||
store.put([shapeRecord])
|
||||
```
|
||||
|
||||
### Custom Shape Validation
|
||||
|
||||
When creating custom shapes, use @tldraw/validate for type safety:
|
||||
|
||||
```ts
|
||||
// Custom shape with validated properties
|
||||
const customShapeValidator = T.object({
|
||||
type: T.literal('custom'),
|
||||
x: T.number,
|
||||
y: T.number,
|
||||
customProperty: T.string.check((value) => {
|
||||
if (!value.startsWith('custom-')) {
|
||||
throw new Error("Custom property must start with 'custom-'")
|
||||
}
|
||||
}),
|
||||
config: T.object({
|
||||
color: T.literalEnum('red', 'blue', 'green'),
|
||||
size: T.positiveNumber,
|
||||
}),
|
||||
})
|
||||
|
||||
export class CustomShapeUtil extends BaseBoxShapeUtil<CustomShape> {
|
||||
static override type = 'custom' as const
|
||||
static override props = customShapeValidator
|
||||
// ... implementation
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Best Practices
|
||||
|
||||
### Validator Organization
|
||||
|
||||
Structure validators for maintainability:
|
||||
|
||||
```ts
|
||||
// Group related validators
|
||||
const UserValidators = {
|
||||
base: T.object({
|
||||
id: T.string,
|
||||
name: T.string,
|
||||
email: T.linkUrl,
|
||||
}),
|
||||
|
||||
create: T.object({
|
||||
name: T.string,
|
||||
email: T.linkUrl,
|
||||
password: T.string.check(validatePasswordStrength),
|
||||
}),
|
||||
|
||||
update: T.object({
|
||||
name: T.string.optional(),
|
||||
email: T.linkUrl.optional(),
|
||||
}),
|
||||
}
|
||||
|
||||
// Compose complex validators from simpler ones
|
||||
const PostValidator = T.object({
|
||||
title: T.string,
|
||||
author: UserValidators.base,
|
||||
tags: T.arrayOf(T.string).optional(),
|
||||
publishedAt: T.string.nullable(),
|
||||
})
|
||||
```
|
||||
|
||||
### Error Handling Strategy
|
||||
|
||||
Design validation with user experience in mind:
|
||||
|
||||
```ts
|
||||
function validateAndProcess(data: unknown) {
|
||||
try {
|
||||
const validated = complexValidator.validate(data)
|
||||
return { success: true, data: validated }
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
// Convert technical error to user-friendly message
|
||||
const userMessage = getUserFriendlyMessage(error.path, error.rawMessage)
|
||||
return { success: false, error: userMessage }
|
||||
}
|
||||
throw error // Re-throw unexpected errors
|
||||
}
|
||||
}
|
||||
|
||||
function getUserFriendlyMessage(path: readonly (string | number)[], message: string) {
|
||||
const field = path.join('.')
|
||||
if (message.includes('Expected a valid url')) {
|
||||
return `Please enter a valid URL for ${field}`
|
||||
}
|
||||
if (message.includes('Expected a positive number')) {
|
||||
return `${field} must be a positive number`
|
||||
}
|
||||
return `Invalid value for ${field}: ${message}`
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
Use validation efficiently:
|
||||
|
||||
```ts
|
||||
// Cache validators for reuse
|
||||
const userValidatorCache = new Map()
|
||||
function getUserValidator(version: string) {
|
||||
if (!userValidatorCache.has(version)) {
|
||||
userValidatorCache.set(version, createUserValidator(version))
|
||||
}
|
||||
return userValidatorCache.get(version)
|
||||
}
|
||||
|
||||
// Use known good validation for updates
|
||||
let currentUser = userValidator.validate(initialData)
|
||||
|
||||
function updateUser(changes: unknown) {
|
||||
// Merge changes with current user
|
||||
const candidate = { ...currentUser, ...changes }
|
||||
|
||||
// Efficient validation using known good value
|
||||
currentUser = userValidator.validateUsingKnownGoodVersion(currentUser, candidate)
|
||||
return currentUser
|
||||
}
|
||||
```
|
||||
|
||||
### Type Safety Patterns
|
||||
|
||||
Leverage TypeScript integration:
|
||||
|
||||
```ts
|
||||
// Extract types from validators
|
||||
type User = TypeOf<typeof userValidator>
|
||||
type CreateUserRequest = TypeOf<typeof createUserValidator>
|
||||
|
||||
// Use in function signatures
|
||||
function processUser(user: User): string {
|
||||
// user is fully typed, no runtime checks needed
|
||||
return `Processing ${user.name} (${user.email})`
|
||||
}
|
||||
|
||||
// Ensure validator matches interface
|
||||
interface ApiResponse {
|
||||
data: User[]
|
||||
total: number
|
||||
}
|
||||
|
||||
const apiResponseValidator: Validator<ApiResponse> = T.object({
|
||||
data: T.arrayOf(userValidator),
|
||||
total: T.nonZeroInteger,
|
||||
})
|
||||
```
|
||||
|
||||
@tldraw/validate provides the foundation for type-safe data validation in tldraw applications. Its performance optimizations, detailed error reporting, and seamless TypeScript integration make it essential for handling external data safely within the tldraw SDK ecosystem.
|
||||
9
packages/validate/LICENSE.md
Normal file
9
packages/validate/LICENSE.md
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 tldraw Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
59
packages/validate/README.md
Normal file
59
packages/validate/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
## @tldraw/validate
|
||||
|
||||
Validation functions used by tldraw.
|
||||
|
||||
These are especially useful when used in conjunction with our shapes to validate their [schemas](https://github.com/tldraw/tldraw/tree/main/packages/tlschema).
|
||||
|
||||
For example, for [TLImageShape](https://github.com/tldraw/tldraw/blob/main/packages/tlschema/src/shapes/TLImageShape.ts) we have the following to help make sure the properties on a shape are consistent:
|
||||
|
||||
```tsx
|
||||
export const imageShapeProps: RecordProps<TLImageShape> = {
|
||||
w: T.nonZeroNumber,
|
||||
h: T.nonZeroNumber,
|
||||
playing: T.boolean,
|
||||
url: T.linkUrl,
|
||||
assetId: assetIdValidator.nullable(),
|
||||
crop: ImageShapeCrop.nullable(),
|
||||
flipX: T.boolean,
|
||||
flipY: T.boolean,
|
||||
}
|
||||
```
|
||||
|
||||
We also use the validation functions for API requests. For example, to check that the query is valid:
|
||||
|
||||
```tsx
|
||||
const queryValidator = T.object({
|
||||
w: T.string.optional(),
|
||||
q: T.string.optional(),
|
||||
})
|
||||
|
||||
queryValidator.validate(request.query)
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
|
||||
|
||||
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
|
||||
|
||||
A `DOCS.md` file is included alongside this README in the published package, with detailed API documentation and usage examples.
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License found [here](https://github.com/tldraw/tldraw/blob/main/packages/utils/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
140
packages/validate/SPEC.md
Normal file
140
packages/validate/SPEC.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# @tldraw/validate behavior specification
|
||||
|
||||
This document states the rules that `@tldraw/validate` implements. It is written to drive testing: each rule has a stable ID (e.g. `N3`, `U5`), each rule is independently observable through the public API, and the unit tests should be an expression of these rules. When a test and this document disagree, one of them is wrong — figure out which and fix it.
|
||||
|
||||
Rules marked **dev** describe assertions that run only in development builds (`NODE_ENV !== 'production'`). The test suite runs in development mode, so these rules are observable there; production builds skip them.
|
||||
|
||||
## 1. Model and vocabulary
|
||||
|
||||
- A **validator** is any object implementing `Validatable<T>`: `validate(value)` either returns the value typed as `T` or throws a `ValidationError`.
|
||||
- A **known-good validation** is the optional fast path `validateUsingKnownGoodVersion(knownGoodValue, newValue)`: given a previously validated value, it validates only what changed and returns the known-good value itself when nothing did, so callers can use reference equality to detect "no change".
|
||||
- A **plain validator** returns exactly the value it was given. A **transforming validator** (built with `refine`, or anything derived from it like `check` and `indexKey`) may return a different value.
|
||||
- A validator's **path** locates a failure inside a nested structure: object keys, array indices, dict keys, union variants, check names, and model names each contribute a segment.
|
||||
|
||||
## 2. Validation errors and paths (E)
|
||||
|
||||
- **E1** `ValidationError` extends `Error`, has `name: 'ValidationError'`, and exposes the unprefixed `rawMessage` and the `path` array it was constructed with (default `[]`).
|
||||
- **E2** The error `message` is `At <formattedPath>: <rawMessage>` when the path is non-empty, and just the raw message when the path is empty.
|
||||
- **E3** Path formatting: string segments are joined with `.`, numeric segments appear as `.<index>`, and the leading dot is stripped (`users.0.email`).
|
||||
- **E4** Parenthesized segments — union variants `(type = cat)` and named checks `(check foo)` — are appended without a dot, and consecutive parenthesized segments merge into one group: a named check on a union variant formats as `(type = cat, check foo)`.
|
||||
- **E5** `id = …` content is stripped from formatted paths so errors group well in Sentry. A union discriminated on `id` therefore formats its variant segment as `()`.
|
||||
- **E6** Multi-line raw messages are indented: every line after the first gets two leading spaces in `message`.
|
||||
- **E7** Nested validators prefix their segment onto errors from inner validators, preserving `rawMessage` and accumulating `path` outside-in. A non-`ValidationError` exception thrown inside a validator is wrapped into a `ValidationError` whose raw message is the exception's `toString()`, with the path segment applied.
|
||||
|
||||
## 3. The validator core (V)
|
||||
|
||||
- **V1** A plain validator's `validate(value)` returns exactly the value it was passed — same reference, no clone, no mutation, no freezing.
|
||||
- **V2** (dev) If a validator function returns a value that is not `Object.is`-equal to its input and was not created through a transforming API, `validate` throws `Validator functions must return the same value they were passed`.
|
||||
- **V3** `isValid(value)` returns `true` when `validate` succeeds and `false` when it throws; it never throws.
|
||||
- **V4** `validateUsingKnownGoodVersion(knownGoodValue, newValue)` returns `knownGoodValue` immediately — without running any validation — when `Object.is(knownGoodValue, newValue)`.
|
||||
- **V5** When the references differ and the validator has no known-good implementation, `validateUsingKnownGoodVersion` falls back to a full `validate(newValue)`.
|
||||
|
||||
## 4. Refinement: refine and check (RC)
|
||||
|
||||
- **RC1** `refine(fn)` validates with the base validator, then passes the result through `fn`; the refined validator returns `fn`'s result, which may have a different type or value. A `ValidationError` thrown by `fn` propagates with path context from the surrounding structure.
|
||||
- **RC2** A refined validator's known-good validation skips the refinement entirely when the base validator reports "no change" (returns the known-good value): the previous _output_ is returned. When the base reports a change, `fn` runs on the new validated value.
|
||||
- **RC3** Because of V4, a refined validator's known-good validation accepts any `newValue` that is `Object.is`-equal to the previous _output_, even if that value would not pass `validate` from scratch (e.g. a number passed to a string-to-number refinement).
|
||||
- **RC4** `check(fn)` adds an assertion without changing the value: `fn`'s return value is ignored, the input passes through. A failure carries no extra path segment.
|
||||
- **RC5** `check(name, fn)` is the same but prefixes failures with a `(check <name>)` path segment.
|
||||
|
||||
## 5. optional and nullable (NO)
|
||||
|
||||
- **NO1** `nullable(v)` (and `v.nullable()`) accepts `null` and returns it without consulting the inner validator; all other values go to the inner validator, so `undefined` is rejected when the inner validator rejects it.
|
||||
- **NO2** `optional(v)` (and `v.optional()`) accepts `undefined` the same way; `null` goes to the inner validator.
|
||||
- **NO3** Known-good validation: a `null`/`undefined` new value short-circuits as in NO1/NO2; a `null`/`undefined` _known-good_ value forces a full inner `validate`; otherwise the inner validator's known-good path is used, preserving identity for unchanged values.
|
||||
- **NO4** Wrapping a transforming validator in `optional`/`nullable` keeps it exempt from the dev same-value check (V2): `T.string.refine(...).optional()` may return transformed values.
|
||||
|
||||
## 6. Primitives (P)
|
||||
|
||||
- **P1** `string`, `boolean`, and `bigint` validate by `typeof` and reject everything else with `Expected <type>, got <description>`.
|
||||
- **P2** The `<description>` in type-mismatch messages is: `null` for null, `an array` for arrays, `undefined` for undefined, `an object` for other objects, and `a <typeof>` for the remaining primitives (`a string`, `a number`, `a boolean`, `a bigint`, `a function`, `a symbol`).
|
||||
- **P3** `unknown` and `any` accept every value — including `undefined` — and return it as-is.
|
||||
- **P4** `literal(expected)` accepts exactly `expected` (strict equality) and rejects everything else with `Expected <expected>, got <JSON.stringify(actual)>`.
|
||||
- **P5** `array` accepts any array without validating its items, and rejects non-arrays with `Expected an array, got <description>`.
|
||||
- **P6** `unknownObject` accepts any non-null value with `typeof 'object'` and rejects `null` and primitives with `Expected object, got <description>`. Arrays pass this check (despite the description machinery knowing them as `an array`) — `T.unknownObject.validate([1, 2, 3])` returns the array.
|
||||
|
||||
## 7. Numbers (N)
|
||||
|
||||
- **N1** `number` accepts finite numbers, rejecting non-numbers (`Expected number, got <description>`), `NaN` (`Expected a number, got NaN`), and `Infinity`/`-Infinity` (`Expected a finite number, got <value>`).
|
||||
- **N2** `positiveNumber` accepts finite numbers `>= 0` — zero included, despite the name. Negative values, including `-Infinity`, report `Expected a positive number, got <value>`; the NaN and non-number messages match N1.
|
||||
- **N3** `nonZeroNumber` accepts finite numbers `> 0`. Zero and negative values, including `-Infinity`, report `Expected a non-zero positive number, got <value>`.
|
||||
- **N4** `nonZeroFiniteNumber` accepts finite numbers that are not zero — negatives allowed. Zero reports `Expected a non-zero number, got 0`.
|
||||
- **N5** `unitInterval` accepts finite numbers in `[0, 1]`. All other numbers — out-of-range values and the infinities alike — report `Expected a number between 0 and 1, got <value>`.
|
||||
- **N6** `integer` accepts whole finite numbers (negatives included) and rejects fractional numbers with `Expected an integer, got <value>`; NaN and the infinities report as in N1.
|
||||
- **N7** `positiveInteger` accepts integers `>= 0` (zero included). Any negative number — integer or fractional, `-1.5` included — reports `Expected a positive integer, got <value>`; non-negative fractional numbers report `Expected an integer, got <value>`.
|
||||
- **N8** `nonZeroInteger` accepts integers `> 0`. Zero and negative numbers report `Expected a non-zero positive integer, got <value>`; positive fractional numbers report `Expected an integer, got <value>`.
|
||||
- **N9** Negative zero is `>= 0` and an integer: `number`, `integer`, `positiveNumber`, and `positiveInteger` accept `-0`; `nonZeroNumber` and `nonZeroFiniteNumber` reject it as zero (`got 0` in the message).
|
||||
|
||||
## 8. Enums (EN)
|
||||
|
||||
- **EN1** `setEnum(set)` accepts exactly the set's members. The failure message lists the allowed values JSON-stringified and the actual value string-interpolated: `Expected "a" or "b", got c`.
|
||||
- **EN2** `literalEnum(...values)` is `setEnum(new Set(values))`.
|
||||
|
||||
## 9. Arrays of validated items (A)
|
||||
|
||||
- **A1** `arrayOf(item)` requires an array and validates every element with `item`, prefixing the failing element's index onto the path.
|
||||
- **A2** `.nonEmpty()` additionally rejects `[]` (`Expected a non-empty array`); `.lengthGreaterThan1()` rejects arrays of length `<= 1` (`Expected an array with length greater than 1`).
|
||||
- **A3** Known-good validation returns the known-good array exactly when the lengths match and every element is unchanged: elements that are `Object.is`-equal to their counterparts are skipped outright, and the rest are revalidated with the item validator's known-good path, counting as unchanged when it returns the known-good element.
|
||||
- **A4** When the length differs or any element changed, known-good validation returns the _new_ array; appended elements are fully validated, and elements past the new length are simply dropped with no validation.
|
||||
- **A5** If the item validator has no known-good implementation, the array's known-good validation falls back to a full `validate` of the new array — which then returns the new array even when it is structurally equal to the known-good one.
|
||||
|
||||
## 10. Objects (O)
|
||||
|
||||
- **O1** `object(config)` requires a non-null object and validates each configured property with its validator, prefixing the property name on failure. A missing property is validated as `undefined` (so required properties fail with `Expected <type>, got undefined`).
|
||||
- **O2** Properties not present in the config are rejected: `At <key>: Unexpected property`.
|
||||
- **O3** `.allowUnknownProperties()` returns a _new_ validator that accepts extra properties without validating them; the original validator is unchanged.
|
||||
- **O4** The object check is `typeof value === 'object' && value !== null`: arrays pass it. An empty array satisfies `T.object({})`; an array with elements then fails O2 on its indices (`At 0: Unexpected property`).
|
||||
- **O5** `.extend(extension)` returns a new object validator over the merged config; extension keys override same-named config keys. The result rejects unknown properties regardless of the receiver's setting.
|
||||
- **O6** Known-good validation returns the known-good object exactly when every configured property is unchanged (`Object.is`-equal or known-good-equal per the property's validator) and no keys were added or removed; otherwise it returns the new object. Only changed properties are revalidated.
|
||||
- **O7** Known-good validation detects removed keys: a key present on the known-good object but absent from the new one makes the new object the result, even if all configured properties are unchanged.
|
||||
- **O8** With `allowUnknownProperties`, known-good validation also compares unknown properties by `Object.is`: adding, removing, or changing an unknown property makes the new object the result.
|
||||
|
||||
## 11. Dictionaries (DI)
|
||||
|
||||
- **DI1** `dict(keyValidator, valueValidator)` requires a non-null object (arrays pass, as in O4) and validates every own enumerable key with `keyValidator` and every value with `valueValidator`, prefixing the key on failure.
|
||||
- **DI2** `jsonDict()` is `dict(string, jsonValue)`.
|
||||
- **DI3** Known-good validation returns the known-good object exactly when the same keys are present and every value is unchanged (`Object.is`-equal or known-good-equal). Added keys are fully validated and make the new object the result; removed keys are detected by comparing key counts; changed values are revalidated incrementally.
|
||||
|
||||
## 12. Discriminated unions (U)
|
||||
|
||||
- **U1** `union(key, config)` reads the discriminator property `key`, selects the matching variant validator, and validates the whole object with it, returning the input on success.
|
||||
- **U2** Non-object inputs are rejected with `Expected an object, got <description>`.
|
||||
- **U3** For string-keyed unions, a missing or non-string discriminator is rejected with `Expected a string for key "<key>", got <description>`.
|
||||
- **U4** A discriminator that matches no variant is rejected with `Expected one of <variants> or …, got <JSON.stringify(variant)>` at path `[key]`.
|
||||
- **U5** Variant validation failures are prefixed with the `(key = variant)` path segment.
|
||||
- **U6** `validateUnknownVariants(handler)` returns a new union that calls `handler(value, variant)` for unmatched variants and uses its return value as the result. (dev) On the `validate` path the same-value check (V2) applies: a handler that returns a new object throws `Validator functions must return the same value they were passed`, so handlers must return the input itself. The known-good path does not run that check.
|
||||
- **U7** Known-good validation with an unchanged discriminator uses the variant's known-good path (preserving identity for unchanged values); a changed discriminator falls back to a full validation against the new variant; unmatched variants go to the unknown-variant handler; a non-object on _either_ side throws per U2.
|
||||
- **U8** `numberUnion(key, config)` requires the discriminator to coerce to a finite number: `Infinity`, `-Infinity`, `NaN`, and non-numeric strings are rejected with `Expected a number for key "<key>", got "<value>"`.
|
||||
- **U9** `numberUnion` looks the variant up by the discriminator's own string coercion, so a string-numeric discriminator like `'1'` selects variant `1` (and then typically fails that variant's `literal`), while an unmatched finite number like `1.5` is rejected per U4.
|
||||
|
||||
## 13. JSON values (J)
|
||||
|
||||
- **J1** `jsonValue` accepts `null`, booleans, finite and non-finite numbers, strings, and arrays and plain objects of accepted values, recursively.
|
||||
- **J2** It rejects `undefined`, functions, bigints, symbols, and class instances — anywhere in the structure, including inside arrays and object values. Sparse array holes read as `undefined`, so sparse arrays are rejected.
|
||||
- **J3** "Plain object" means the prototype is `Object.prototype`, `null`, or the structured-clone prototype: `Object.create(null)` and `structuredClone` results validate.
|
||||
- **J4** The full-validation failure message reports the `typeof` of the _root_ value (`Expected json serializable value, got object`), even when the offending value is nested.
|
||||
- **J5** Known-good validation is incremental when both values are arrays or both are plain objects: unchanged (`Object.is`-equal) entries are skipped, added/changed entries are validated recursively, and the known-good value is returned exactly when nothing changed — length changes and removed keys make the new value the result.
|
||||
- **J6** When the two values' shapes differ (array vs object vs scalar), known-good validation falls back to a full validation of the new value.
|
||||
|
||||
## 14. Models (M)
|
||||
|
||||
- **M1** `model(name, validator)` validates with the inner validator and prefixes `name` as the leading path segment on failure (`At user.email: …`).
|
||||
- **M2** Its known-good validation delegates to the inner validator's (with the same prefixing), preserving identity for unchanged values.
|
||||
|
||||
## 15. URL validators (UR)
|
||||
|
||||
- **UR1** `linkUrl` accepts the empty string and absolute URLs with `http:`, `https:`, or `mailto:` protocols; other protocols are rejected with `(invalid protocol)` and unparseable strings with `Expected a valid url, got <string>`.
|
||||
- **UR2** `srcUrl` is the same with the allowed protocols `http:`, `https:`, `data:`, and `asset:`.
|
||||
- **UR3** `httpUrl` is the same with only `http:` and `https:` allowed.
|
||||
- **UR4** Strings starting with `/` or `./` are parsed against a dummy `http:` origin and therefore validate against all three; other relative forms (`../x`, `foo`) are rejected.
|
||||
- **UR5** Protocol matching is case-insensitive: `HTTP://example.com` validates, `JAVASCRIPT:alert(1)` does not.
|
||||
- **UR6** All three are string validators first: non-strings fail with the P1 message.
|
||||
|
||||
## 16. Index keys (IK)
|
||||
|
||||
- **IK1** `indexKey` accepts strings that are valid fractional index keys (`'a0'`, `'a1J'`) and rejects invalid ones (`'a'`, `'a00'`, `''`) with `Expected an index key, got <JSON.stringify(key)>`.
|
||||
|
||||
## 17. Either-of (OR)
|
||||
|
||||
- **OR1** `or(v1, v2)` returns `v1.validate(value)` when it succeeds; when `v1` throws — for any reason — the value goes to `v2` and `v2`'s result is returned.
|
||||
- **OR2** When both fail, `v2`'s error propagates; `v1`'s error is swallowed.
|
||||
4
packages/validate/api-extractor.json
Normal file
4
packages/validate/api-extractor.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
"extends": "../../internal/config/api-extractor.json"
|
||||
}
|
||||
260
packages/validate/api-report.api.md
Normal file
260
packages/validate/api-report.api.md
Normal file
@@ -0,0 +1,260 @@
|
||||
## API Report File for "@tldraw/validate"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
|
||||
import { IndexKey } from '@tldraw/utils';
|
||||
import { JsonValue } from '@tldraw/utils';
|
||||
import { MakeUndefinedOptional } from '@tldraw/utils';
|
||||
|
||||
// @public
|
||||
const any: Validator<any>;
|
||||
|
||||
// @public
|
||||
const array: Validator<unknown[]>;
|
||||
|
||||
// @public
|
||||
function arrayOf<T>(itemValidator: Validatable<T>): ArrayOfValidator<T>;
|
||||
|
||||
// @public
|
||||
export class ArrayOfValidator<T> extends Validator<T[]> {
|
||||
constructor(itemValidator: Validatable<T>);
|
||||
// (undocumented)
|
||||
readonly itemValidator: Validatable<T>;
|
||||
lengthGreaterThan1(): Validator<T[]>;
|
||||
nonEmpty(): Validator<T[]>;
|
||||
}
|
||||
|
||||
// @public
|
||||
const bigint: Validator<bigint>;
|
||||
|
||||
// @public
|
||||
const boolean: Validator<boolean>;
|
||||
|
||||
// @public
|
||||
function dict<Key extends string, Value>(keyValidator: Validatable<Key>, valueValidator: Validatable<Value>): DictValidator<Key, Value>;
|
||||
|
||||
// @public
|
||||
export class DictValidator<Key extends string, Value> extends Validator<Record<Key, Value>> {
|
||||
constructor(keyValidator: Validatable<Key>, valueValidator: Validatable<Value>);
|
||||
// (undocumented)
|
||||
readonly keyValidator: Validatable<Key>;
|
||||
// (undocumented)
|
||||
readonly valueValidator: Validatable<Value>;
|
||||
}
|
||||
|
||||
// @public
|
||||
const httpUrl: Validator<string>;
|
||||
|
||||
// @public
|
||||
const indexKey: Validator<IndexKey>;
|
||||
|
||||
// @public
|
||||
const integer: Validator<number>;
|
||||
|
||||
// @public
|
||||
function jsonDict(): DictValidator<string, JsonValue>;
|
||||
|
||||
// @public
|
||||
const jsonValue: Validator<JsonValue>;
|
||||
|
||||
// @public
|
||||
const linkUrl: Validator<string>;
|
||||
|
||||
// @public
|
||||
function literal<T extends boolean | number | string>(expectedValue: T): Validator<T>;
|
||||
|
||||
// @public
|
||||
function literalEnum<const Values extends readonly unknown[]>(...values: Values): Validator<Values[number]>;
|
||||
|
||||
// @public
|
||||
function model<T extends {
|
||||
readonly id: string;
|
||||
}>(name: string, validator: Validatable<T>): Validator<T>;
|
||||
|
||||
// @public
|
||||
const nonZeroFiniteNumber: Validator<number>;
|
||||
|
||||
// @public
|
||||
const nonZeroInteger: Validator<number>;
|
||||
|
||||
// @public
|
||||
const nonZeroNumber: Validator<number>;
|
||||
|
||||
// @public
|
||||
function nullable<T>(validator: Validatable<T>): Validator<null | T>;
|
||||
|
||||
// @public
|
||||
const number: Validator<number>;
|
||||
|
||||
// @internal
|
||||
function numberUnion<Key extends string, Config extends UnionValidatorConfig<Key, Config>>(key: Key, config: Config): UnionValidator<Key, Config>;
|
||||
|
||||
// @public
|
||||
function object<Shape extends object>(config: {
|
||||
readonly [K in keyof Shape]: Validatable<Shape[K]>;
|
||||
}): ObjectValidator<MakeUndefinedOptional<Shape>>;
|
||||
|
||||
// @public
|
||||
export class ObjectValidator<Shape extends object> extends Validator<Shape> {
|
||||
constructor(config: {
|
||||
readonly [K in keyof Shape]: Validatable<Shape[K]>;
|
||||
}, shouldAllowUnknownProperties?: boolean);
|
||||
allowUnknownProperties(): ObjectValidator<Shape>;
|
||||
// (undocumented)
|
||||
readonly config: {
|
||||
readonly [K in keyof Shape]: Validatable<Shape[K]>;
|
||||
};
|
||||
extend<Extension extends Record<string, unknown>>(extension: {
|
||||
readonly [K in keyof Extension]: Validatable<Extension[K]>;
|
||||
}): ObjectValidator<Shape & Extension>;
|
||||
}
|
||||
|
||||
// @public
|
||||
function optional<T>(validator: Validatable<T>): Validator<T | undefined>;
|
||||
|
||||
// @public
|
||||
function or<T1, T2>(v1: Validatable<T1>, v2: Validatable<T2>): Validator<T1 | T2>;
|
||||
|
||||
// @public
|
||||
const positiveInteger: Validator<number>;
|
||||
|
||||
// @public
|
||||
const positiveNumber: Validator<number>;
|
||||
|
||||
// @public
|
||||
function setEnum<T>(values: ReadonlySet<T>): Validator<T>;
|
||||
|
||||
// @public
|
||||
const srcUrl: Validator<string>;
|
||||
|
||||
// @public
|
||||
const string: Validator<string>;
|
||||
|
||||
declare namespace T {
|
||||
export {
|
||||
literal,
|
||||
arrayOf,
|
||||
object,
|
||||
jsonDict,
|
||||
dict,
|
||||
union,
|
||||
numberUnion,
|
||||
model,
|
||||
setEnum,
|
||||
optional,
|
||||
nullable,
|
||||
literalEnum,
|
||||
or,
|
||||
ValidatorFn,
|
||||
ValidatorUsingKnownGoodVersionFn,
|
||||
Validatable,
|
||||
ValidationError,
|
||||
TypeOf,
|
||||
Validator,
|
||||
ArrayOfValidator,
|
||||
ObjectValidator,
|
||||
UnionValidatorConfig,
|
||||
UnionValidator,
|
||||
DictValidator,
|
||||
unknown,
|
||||
any,
|
||||
string,
|
||||
number,
|
||||
positiveNumber,
|
||||
nonZeroNumber,
|
||||
nonZeroFiniteNumber,
|
||||
unitInterval,
|
||||
integer,
|
||||
positiveInteger,
|
||||
nonZeroInteger,
|
||||
boolean,
|
||||
bigint,
|
||||
array,
|
||||
unknownObject,
|
||||
jsonValue,
|
||||
linkUrl,
|
||||
srcUrl,
|
||||
httpUrl,
|
||||
indexKey
|
||||
}
|
||||
}
|
||||
export { T }
|
||||
|
||||
// @public
|
||||
type TypeOf<V extends Validatable<any>> = V extends Validatable<infer T> ? T : never;
|
||||
|
||||
// @public
|
||||
function union<Key extends string, Config extends UnionValidatorConfig<Key, Config>>(key: Key, config: Config): UnionValidator<Key, Config>;
|
||||
|
||||
// @public
|
||||
export class UnionValidator<Key extends string, Config extends UnionValidatorConfig<Key, Config>, UnknownValue = never> extends Validator<TypeOf<Config[keyof Config]> | UnknownValue> {
|
||||
constructor(key: Key, config: Config, unknownValueValidation: (value: object, variant: string) => UnknownValue, useNumberKeys: boolean);
|
||||
validateUnknownVariants<Unknown>(unknownValueValidation: (value: object, variant: string) => Unknown): UnionValidator<Key, Config, Unknown>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type UnionValidatorConfig<Key extends string, Config> = {
|
||||
readonly [Variant in keyof Config]: Validatable<any> & {
|
||||
validate(input: any): {
|
||||
readonly [K in Key]: Variant;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
const unitInterval: Validator<number>;
|
||||
|
||||
// @public
|
||||
const unknown: Validator<unknown>;
|
||||
|
||||
// @public
|
||||
const unknownObject: Validator<Record<string, unknown>>;
|
||||
|
||||
// @public
|
||||
interface Validatable<T> {
|
||||
validate(value: unknown): T;
|
||||
validateUsingKnownGoodVersion?(knownGoodValue: T, newValue: unknown): T;
|
||||
}
|
||||
|
||||
// @public
|
||||
class ValidationError extends Error {
|
||||
constructor(rawMessage: string, path?: ReadonlyArray<number | string>);
|
||||
// (undocumented)
|
||||
name: string;
|
||||
// (undocumented)
|
||||
readonly path: ReadonlyArray<number | string>;
|
||||
// (undocumented)
|
||||
readonly rawMessage: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class Validator<T> implements Validatable<T> {
|
||||
constructor(validationFn: ValidatorFn<T>, validateUsingKnownGoodVersionFn?: undefined | ValidatorUsingKnownGoodVersionFn<T>,
|
||||
skipSameValueCheck?: boolean);
|
||||
check(name: string, checkFn: (value: T) => void): Validator<T>;
|
||||
check(checkFn: (value: T) => void): Validator<T>;
|
||||
isValid(value: unknown): value is T;
|
||||
nullable(): Validator<null | T>;
|
||||
optional(): Validator<T | undefined>;
|
||||
refine<U>(otherValidationFn: (value: T) => U): Validator<U>;
|
||||
// @internal (undocumented)
|
||||
readonly skipSameValueCheck: boolean;
|
||||
validate(value: unknown): T;
|
||||
validateUsingKnownGoodVersion(knownGoodValue: T, newValue: unknown): T;
|
||||
// (undocumented)
|
||||
readonly validateUsingKnownGoodVersionFn?: undefined | ValidatorUsingKnownGoodVersionFn<T>;
|
||||
// (undocumented)
|
||||
readonly validationFn: ValidatorFn<T>;
|
||||
}
|
||||
|
||||
// @public
|
||||
type ValidatorFn<T> = (value: unknown) => T;
|
||||
|
||||
// @public
|
||||
type ValidatorUsingKnownGoodVersionFn<In, Out = In> = (knownGoodValue: In, value: unknown) => Out;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
```
|
||||
60
packages/validate/package.json
Normal file
60
packages/validate/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@tldraw/validate",
|
||||
"description": "A runtime validation library by tldraw.",
|
||||
"version": "5.4.2",
|
||||
"author": {
|
||||
"name": "tldraw Inc.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"homepage": "https://tldraw.dev",
|
||||
"license": "MIT",
|
||||
"tldraw_product": {
|
||||
"stableId": "tldraw:sdk-core",
|
||||
"name": "SDK",
|
||||
"type": "product",
|
||||
"premium": false
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tldraw/tldraw"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tldraw/tldraw/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"tldraw",
|
||||
"sdk",
|
||||
"drawing",
|
||||
"app",
|
||||
"development",
|
||||
"whiteboard",
|
||||
"canvas",
|
||||
"infinite"
|
||||
],
|
||||
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./.tsbuild/index.d.ts",
|
||||
"type": "module",
|
||||
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
|
||||
"files": [],
|
||||
"scripts": {
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
||||
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
|
||||
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
|
||||
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
|
||||
"postpack": "../../internal/scripts/postpack.sh",
|
||||
"pack-tarball": "yarn pack",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tldraw/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"lazyrepo": "0.0.0-alpha.27"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
18
packages/validate/src/index.ts
Normal file
18
packages/validate/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { registerTldrawLibraryVersion } from '@tldraw/utils'
|
||||
import * as T from './lib/validation'
|
||||
|
||||
export {
|
||||
ArrayOfValidator,
|
||||
DictValidator,
|
||||
ObjectValidator,
|
||||
UnionValidator,
|
||||
Validator,
|
||||
type UnionValidatorConfig,
|
||||
} from './lib/validation'
|
||||
export { T }
|
||||
|
||||
registerTldrawLibraryVersion(
|
||||
(globalThis as any).TLDRAW_LIBRARY_NAME,
|
||||
(globalThis as any).TLDRAW_LIBRARY_VERSION,
|
||||
(globalThis as any).TLDRAW_LIBRARY_MODULES
|
||||
)
|
||||
2003
packages/validate/src/lib/validation.ts
Normal file
2003
packages/validate/src/lib/validation.ts
Normal file
File diff suppressed because it is too large
Load Diff
103
packages/validate/src/test/arrays.test.ts
Normal file
103
packages/validate/src/test/arrays.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as T from '../lib/validation'
|
||||
import { ValidationError } from '../lib/validation'
|
||||
|
||||
/** An item validator with a counting validate and a known-good implementation. */
|
||||
function countingItemValidator() {
|
||||
const counts = { validate: 0, knownGood: 0 }
|
||||
const validator: T.Validatable<number> = {
|
||||
validate: (value) => {
|
||||
counts.validate++
|
||||
if (typeof value !== 'number') throw new ValidationError('Expected a number item')
|
||||
return value
|
||||
},
|
||||
validateUsingKnownGoodVersion: (knownGood, value) => {
|
||||
counts.knownGood++
|
||||
if (typeof value !== 'number') throw new ValidationError('Expected a number item')
|
||||
return Object.is(knownGood, value) ? knownGood : (value as number)
|
||||
},
|
||||
}
|
||||
return { validator, counts }
|
||||
}
|
||||
|
||||
describe('§9 Arrays of validated items', () => {
|
||||
it('[A1] arrayOf validates every element and prefixes the failing index', () => {
|
||||
const validator = T.arrayOf(T.number)
|
||||
const arr = [1, 2, 3]
|
||||
expect(validator.validate(arr)).toBe(arr)
|
||||
|
||||
expect(() => validator.validate([1, '2', 3])).toThrow('At 1: Expected number, got a string')
|
||||
expect(() => validator.validate('nope')).toThrow('Expected an array, got a string')
|
||||
})
|
||||
|
||||
it('[A2] nonEmpty rejects empty arrays and lengthGreaterThan1 rejects singletons', () => {
|
||||
expect(T.arrayOf(T.string).nonEmpty().validate(['a'])).toEqual(['a'])
|
||||
expect(() => T.arrayOf(T.string).nonEmpty().validate([])).toThrow('Expected a non-empty array')
|
||||
|
||||
expect(T.arrayOf(T.string).lengthGreaterThan1().validate(['a', 'b'])).toEqual(['a', 'b'])
|
||||
expect(() => T.arrayOf(T.string).lengthGreaterThan1().validate(['a'])).toThrow(
|
||||
'Expected an array with length greater than 1'
|
||||
)
|
||||
expect(() => T.arrayOf(T.string).lengthGreaterThan1().validate([])).toThrow(
|
||||
'Expected an array with length greater than 1'
|
||||
)
|
||||
})
|
||||
|
||||
it('[A3] known-good validation returns the known-good array when nothing changed', () => {
|
||||
const validator = T.arrayOf(T.number)
|
||||
const knownGood = validator.validate([1, 2, 3])
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, [1, 2, 3])).toBe(knownGood)
|
||||
})
|
||||
|
||||
it('[A3] unchanged elements are skipped outright; changed ones go through the item known-good path', () => {
|
||||
const { validator: item, counts } = countingItemValidator()
|
||||
const validator = T.arrayOf(item)
|
||||
const knownGood = validator.validate([1, 2])
|
||||
expect(counts).toEqual({ validate: 2, knownGood: 0 })
|
||||
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, [1, 2])).toBe(knownGood)
|
||||
expect(counts).toEqual({ validate: 2, knownGood: 0 })
|
||||
|
||||
const next = [1, 5]
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
||||
expect(counts).toEqual({ validate: 2, knownGood: 1 })
|
||||
})
|
||||
|
||||
it('[A4] a changed length returns the new array, validating only appended elements', () => {
|
||||
const { validator: item, counts } = countingItemValidator()
|
||||
const validator = T.arrayOf(item)
|
||||
const knownGood = validator.validate([1, 2])
|
||||
counts.validate = 0
|
||||
|
||||
const longer = [1, 2, 3]
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, longer)).toBe(longer)
|
||||
expect(counts).toEqual({ validate: 1, knownGood: 0 })
|
||||
|
||||
const shorter = [1]
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, shorter)).toBe(shorter)
|
||||
expect(counts).toEqual({ validate: 1, knownGood: 0 })
|
||||
})
|
||||
|
||||
it('[A4] invalid changed or appended elements fail with their index', () => {
|
||||
const validator = T.arrayOf(T.number)
|
||||
const knownGood = validator.validate([1, 2])
|
||||
expect(() => validator.validateUsingKnownGoodVersion(knownGood, [1, 'x'])).toThrow(
|
||||
'At 1: Expected number, got a string'
|
||||
)
|
||||
expect(() => validator.validateUsingKnownGoodVersion(knownGood, [1, 2, 'x'])).toThrow(
|
||||
'At 2: Expected number, got a string'
|
||||
)
|
||||
})
|
||||
|
||||
it('[A5] without an item known-good implementation the new array is returned even when structurally equal', () => {
|
||||
const item: T.Validatable<number> = {
|
||||
validate: (value) => {
|
||||
if (typeof value !== 'number') throw new ValidationError('Expected a number item')
|
||||
return value
|
||||
},
|
||||
}
|
||||
const validator = T.arrayOf(item)
|
||||
const knownGood = validator.validate([1, 2])
|
||||
const next = [1, 2]
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
||||
})
|
||||
})
|
||||
86
packages/validate/src/test/dicts.test.ts
Normal file
86
packages/validate/src/test/dicts.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as T from '../lib/validation'
|
||||
import { ValidationError } from '../lib/validation'
|
||||
|
||||
describe('§11 Dictionaries', () => {
|
||||
it('[DI1] dict validates every key and value, prefixing the key on failure', () => {
|
||||
const validator = T.dict(T.string, T.number)
|
||||
const value = { alice: 100, bob: 85 }
|
||||
expect(validator.validate(value)).toBe(value)
|
||||
|
||||
expect(() => validator.validate({ alice: 'x' })).toThrow(
|
||||
'At alice: Expected number, got a string'
|
||||
)
|
||||
|
||||
const shortKeys = T.dict(
|
||||
T.string.check((key) => {
|
||||
if (key.length > 2) throw new ValidationError('key too long')
|
||||
}),
|
||||
T.number
|
||||
)
|
||||
expect(() => shortKeys.validate({ abc: 1 })).toThrow('At abc: key too long')
|
||||
})
|
||||
|
||||
it('[DI1] non-objects and null are rejected; arrays pass the object check', () => {
|
||||
const validator = T.dict(T.string, T.number)
|
||||
expect(() => validator.validate(null)).toThrow('Expected object, got null')
|
||||
expect(() => validator.validate('x')).toThrow('Expected object, got a string')
|
||||
|
||||
const arr = [1, 2]
|
||||
expect(validator.validate(arr)).toBe(arr)
|
||||
})
|
||||
|
||||
it('[DI2] jsonDict validates string keys and JSON values', () => {
|
||||
const value = { a: 'x', b: 42, c: ['a', 'b'], d: { nested: true }, e: null }
|
||||
expect(T.jsonDict().validate(value)).toBe(value)
|
||||
expect(() => T.jsonDict().validate({ a: () => {} })).toThrow(
|
||||
'At a: Expected json serializable value, got function'
|
||||
)
|
||||
})
|
||||
|
||||
it('[DI3] known-good validation returns the known-good object when nothing changed', () => {
|
||||
const validator = T.dict(T.string, T.number)
|
||||
const knownGood = validator.validate({ a: 1, b: 2 })
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, { a: 1, b: 2 })).toBe(knownGood)
|
||||
})
|
||||
|
||||
it('[DI3] added keys are fully validated and produce the new object', () => {
|
||||
const validator = T.dict(T.string, T.number)
|
||||
const knownGood = validator.validate({ a: 1, b: 2 })
|
||||
|
||||
const added = { a: 1, b: 2, c: 3 }
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, added)).toBe(added)
|
||||
expect(() =>
|
||||
validator.validateUsingKnownGoodVersion(knownGood, { a: 1, b: 2, c: 'x' })
|
||||
).toThrow('At c: Expected number, got a string')
|
||||
})
|
||||
|
||||
it('[DI3] removed keys are detected', () => {
|
||||
const validator = T.dict(T.string, T.number)
|
||||
const knownGood = validator.validate({ a: 1, b: 2 })
|
||||
const removed = { a: 1 }
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, removed)).toBe(removed)
|
||||
|
||||
// a swap (one removed, one added) is also detected
|
||||
const swapped = { a: 1, c: 2 }
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, swapped)).toBe(swapped)
|
||||
})
|
||||
|
||||
it('[DI3] changed values are revalidated incrementally', () => {
|
||||
let calls = 0
|
||||
const counting = new T.Validator<number>((value) => {
|
||||
calls++
|
||||
return T.number.validate(value)
|
||||
})
|
||||
const validator = T.dict(T.string, counting)
|
||||
const knownGood = validator.validate({ a: 1, b: 2 })
|
||||
calls = 0
|
||||
|
||||
const next = { a: 1, b: 3 }
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
||||
expect(calls).toBe(1)
|
||||
|
||||
expect(() => validator.validateUsingKnownGoodVersion(knownGood, { a: 1, b: 'x' })).toThrow(
|
||||
'At b: Expected number, got a string'
|
||||
)
|
||||
})
|
||||
})
|
||||
28
packages/validate/src/test/enums.test.ts
Normal file
28
packages/validate/src/test/enums.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as T from '../lib/validation'
|
||||
|
||||
describe('§8 Enums', () => {
|
||||
it('[EN1] setEnum accepts exactly the set members', () => {
|
||||
const validator = T.setEnum(new Set(['red', 'green', 'blue']))
|
||||
expect(validator.validate('red')).toBe('red')
|
||||
expect(validator.validate('blue')).toBe('blue')
|
||||
expect(() => validator.validate('yellow')).toThrow(
|
||||
'Expected "red" or "green" or "blue", got yellow'
|
||||
)
|
||||
})
|
||||
|
||||
it('[EN1] the failure message JSON-stringifies the allowed values and string-interpolates the actual one', () => {
|
||||
expect(() => T.setEnum(new Set([1, 2])).validate(3)).toThrow('Expected 1 or 2, got 3')
|
||||
expect(() => T.setEnum(new Set(['a', 'b'])).validate({ toString: () => 'OBJ' })).toThrow(
|
||||
'Expected "a" or "b", got OBJ'
|
||||
)
|
||||
})
|
||||
|
||||
it('[EN2] literalEnum behaves as a setEnum over its arguments', () => {
|
||||
const validator = T.literalEnum('light', 'dark', 'auto')
|
||||
expect(validator.validate('light')).toBe('light')
|
||||
expect(validator.validate('auto')).toBe('auto')
|
||||
expect(() => validator.validate('blue')).toThrow(
|
||||
'Expected "light" or "dark" or "auto", got blue'
|
||||
)
|
||||
})
|
||||
})
|
||||
124
packages/validate/src/test/errors.test.ts
Normal file
124
packages/validate/src/test/errors.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import * as T from '../lib/validation'
|
||||
import { ValidationError } from '../lib/validation'
|
||||
|
||||
describe('§2 Validation errors and paths', () => {
|
||||
it('[E1] exposes name, rawMessage, and path', () => {
|
||||
const error = new ValidationError('msg', ['a', 0, 'b'])
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.name).toBe('ValidationError')
|
||||
expect(error.rawMessage).toBe('msg')
|
||||
expect(error.path).toEqual(['a', 0, 'b'])
|
||||
|
||||
const noPath = new ValidationError('msg')
|
||||
expect(noPath.path).toEqual([])
|
||||
})
|
||||
|
||||
it('[E2] prefixes the formatted path onto the message when the path is non-empty', () => {
|
||||
expect(new ValidationError('msg', ['a', 0, 'b']).message).toBe('At a.0.b: msg')
|
||||
})
|
||||
|
||||
it('[E2] uses the raw message alone when the path is empty', () => {
|
||||
expect(new ValidationError('msg').message).toBe('msg')
|
||||
expect(new ValidationError('msg', []).message).toBe('msg')
|
||||
|
||||
try {
|
||||
T.string.validate(5)
|
||||
throw new Error('should have thrown')
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toBe('Expected string, got a number')
|
||||
}
|
||||
})
|
||||
|
||||
it('[E3] joins string segments with dots and renders numeric indices', () => {
|
||||
const validator = T.object({
|
||||
toad: T.object({
|
||||
friends: T.arrayOf(T.object({ name: T.string })),
|
||||
}),
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
validator.validate({ toad: { friends: [{ name: 'bird' }, { name: 1235 }] } })
|
||||
).toThrow('At toad.friends.1.name: Expected string, got a number')
|
||||
})
|
||||
|
||||
it('[E4] appends parenthesized segments without a dot', () => {
|
||||
const animal = T.union('type', {
|
||||
cat: T.object({ type: T.literal('cat'), id: T.string, meow: T.boolean }),
|
||||
dog: T.object({ type: T.literal('dog'), id: T.string, bark: T.boolean }),
|
||||
})
|
||||
const nested = T.object({ animal })
|
||||
|
||||
expect(() => nested.validate({ animal: { type: 'cat', meow: 'yes', id: 'abc123' } })).toThrow(
|
||||
'At animal(type = cat).meow: Expected boolean, got a string'
|
||||
)
|
||||
})
|
||||
|
||||
it('[E4] merges consecutive parenthesized segments into one group', () => {
|
||||
const validator = T.union('type', {
|
||||
cat: T.object({ type: T.literal('cat'), n: T.number }).check('named', () => {
|
||||
throw new ValidationError('boom')
|
||||
}),
|
||||
})
|
||||
|
||||
expect(() => validator.validate({ type: 'cat', n: 1 })).toThrow(
|
||||
'At (type = cat, check named): boom'
|
||||
)
|
||||
})
|
||||
|
||||
it('[E5] strips id = … content from formatted paths', () => {
|
||||
const validator = T.object({
|
||||
thing: T.union('id', {
|
||||
foo: T.object({ id: T.literal('foo'), n: T.number }),
|
||||
}),
|
||||
})
|
||||
|
||||
expect(() => validator.validate({ thing: { id: 'foo', n: 'bad' } })).toThrow(
|
||||
'At thing().n: Expected number, got a string'
|
||||
)
|
||||
})
|
||||
|
||||
it('[E6] indents every line after the first in multi-line messages', () => {
|
||||
const validator = T.object({
|
||||
x: T.number.check(() => {
|
||||
throw new ValidationError('line1\nline2\nline3')
|
||||
}),
|
||||
})
|
||||
|
||||
expect(() => validator.validate({ x: 1 })).toThrow('At x: line1\n line2\n line3')
|
||||
})
|
||||
|
||||
it('[E7] accumulates the path outside-in while preserving rawMessage', () => {
|
||||
const validator = T.object({
|
||||
users: T.arrayOf(T.object({ email: T.string })),
|
||||
})
|
||||
|
||||
try {
|
||||
validator.validate({ users: [{ email: 'a@b.com' }, { email: 42 }] })
|
||||
throw new Error('should have thrown')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ValidationError)
|
||||
expect((error as ValidationError).rawMessage).toBe('Expected string, got a number')
|
||||
expect((error as ValidationError).path).toEqual(['users', 1, 'email'])
|
||||
expect((error as ValidationError).message).toBe(
|
||||
'At users.1.email: Expected string, got a number'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('[E7] wraps non-ValidationError exceptions using their toString', () => {
|
||||
const validator = T.object({
|
||||
x: T.number.check(() => {
|
||||
throw new Error('plain')
|
||||
}),
|
||||
})
|
||||
|
||||
try {
|
||||
validator.validate({ x: 1 })
|
||||
throw new Error('should have thrown')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ValidationError)
|
||||
expect((error as ValidationError).rawMessage).toBe('Error: plain')
|
||||
expect((error as ValidationError).path).toEqual(['x'])
|
||||
}
|
||||
})
|
||||
})
|
||||
15
packages/validate/src/test/index-key.test.ts
Normal file
15
packages/validate/src/test/index-key.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as T from '../lib/validation'
|
||||
|
||||
describe('§16 Index keys', () => {
|
||||
it('[IK1] accepts valid fractional index keys', () => {
|
||||
expect(T.indexKey.validate('a0')).toBe('a0')
|
||||
expect(T.indexKey.validate('a1J')).toBe('a1J')
|
||||
})
|
||||
|
||||
it('[IK1] rejects invalid index keys', () => {
|
||||
expect(() => T.indexKey.validate('a')).toThrow('Expected an index key, got "a"')
|
||||
expect(() => T.indexKey.validate('a00')).toThrow('Expected an index key, got "a00"')
|
||||
expect(() => T.indexKey.validate('')).toThrow('Expected an index key, got ""')
|
||||
expect(() => T.indexKey.validate(5)).toThrow('Expected string, got a number')
|
||||
})
|
||||
})
|
||||
108
packages/validate/src/test/json.test.ts
Normal file
108
packages/validate/src/test/json.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as T from '../lib/validation'
|
||||
|
||||
describe('§13 JSON values', () => {
|
||||
it('[J1] accepts JSON scalars, arrays, and plain objects recursively', () => {
|
||||
const value = { name: 'Alice', scores: [1, 2, 3], active: true, meta: null, nested: { a: 'b' } }
|
||||
expect(T.jsonValue.validate(value)).toBe(value)
|
||||
expect(T.jsonValue.validate('x')).toBe('x')
|
||||
expect(T.jsonValue.validate(42)).toBe(42)
|
||||
expect(T.jsonValue.validate(false)).toBe(false)
|
||||
expect(T.jsonValue.validate(null)).toBe(null)
|
||||
})
|
||||
|
||||
it('[J1] accepts non-finite numbers', () => {
|
||||
expect(T.jsonValue.validate(NaN)).toBe(NaN)
|
||||
expect(T.jsonValue.validate(Infinity)).toBe(Infinity)
|
||||
})
|
||||
|
||||
it('[J2] rejects non-JSON values anywhere in the structure', () => {
|
||||
class Foo {}
|
||||
expect(T.jsonValue.isValid(undefined)).toBe(false)
|
||||
expect(T.jsonValue.isValid(() => {})).toBe(false)
|
||||
expect(T.jsonValue.isValid(1n)).toBe(false)
|
||||
expect(T.jsonValue.isValid(Symbol('s'))).toBe(false)
|
||||
expect(T.jsonValue.isValid(new Foo())).toBe(false)
|
||||
expect(T.jsonValue.isValid({ a: undefined })).toBe(false)
|
||||
expect(T.jsonValue.isValid([1, () => {}])).toBe(false)
|
||||
expect(T.jsonValue.isValid({ deep: { deeper: [1n] } })).toBe(false)
|
||||
})
|
||||
|
||||
it('[J2] rejects sparse arrays, whose holes read as undefined', () => {
|
||||
// eslint-disable-next-line no-sparse-arrays
|
||||
expect(T.jsonValue.isValid([1, , 3])).toBe(false)
|
||||
expect(T.jsonValue.isValid(new Array(3))).toBe(false)
|
||||
})
|
||||
|
||||
it('[J3] accepts null-prototype and structured-clone objects as plain objects', () => {
|
||||
expect(T.jsonValue.isValid(Object.create(null))).toBe(true)
|
||||
expect(T.jsonValue.isValid(structuredClone({ a: 1 }))).toBe(true)
|
||||
})
|
||||
|
||||
it('[J4] the full-validation failure message reports the typeof of the root value', () => {
|
||||
expect(() => T.jsonValue.validate({ a: undefined })).toThrow(
|
||||
'Expected json serializable value, got object'
|
||||
)
|
||||
expect(() => T.jsonValue.validate(() => {})).toThrow(
|
||||
'Expected json serializable value, got function'
|
||||
)
|
||||
})
|
||||
|
||||
it('[J5] known-good validation is incremental and identity-preserving for arrays', () => {
|
||||
const knownGood = T.jsonValue.validate([1, [2], 'x'])
|
||||
expect(T.jsonValue.validateUsingKnownGoodVersion(knownGood, [1, [2], 'x'])).toBe(knownGood)
|
||||
|
||||
const changed = [1, [2], 'y']
|
||||
expect(T.jsonValue.validateUsingKnownGoodVersion(knownGood, changed)).toBe(changed)
|
||||
|
||||
const longer = [1, [2], 'x', 4]
|
||||
expect(T.jsonValue.validateUsingKnownGoodVersion(knownGood, longer)).toBe(longer)
|
||||
|
||||
const shorter = [1, [2]]
|
||||
expect(T.jsonValue.validateUsingKnownGoodVersion(knownGood, shorter)).toBe(shorter)
|
||||
})
|
||||
|
||||
it('[J5] known-good validation is incremental and identity-preserving for objects', () => {
|
||||
const knownGood = T.jsonValue.validate({ a: [1, 2], b: 'x' })
|
||||
expect(T.jsonValue.validateUsingKnownGoodVersion(knownGood, { a: [1, 2], b: 'x' })).toBe(
|
||||
knownGood
|
||||
)
|
||||
|
||||
const changed = { a: [1, 2], b: 'y' }
|
||||
expect(T.jsonValue.validateUsingKnownGoodVersion(knownGood, changed)).toBe(changed)
|
||||
|
||||
const removed = { a: [1, 2] }
|
||||
expect(T.jsonValue.validateUsingKnownGoodVersion(knownGood, removed)).toBe(removed)
|
||||
|
||||
const added = { a: [1, 2], b: 'x', c: 1 }
|
||||
expect(T.jsonValue.validateUsingKnownGoodVersion(knownGood, added)).toBe(added)
|
||||
})
|
||||
|
||||
it('[J5] added and changed entries are validated', () => {
|
||||
const knownGoodArr = T.jsonValue.validate([1, 2])
|
||||
expect(() => T.jsonValue.validateUsingKnownGoodVersion(knownGoodArr, [1, () => {}])).toThrow()
|
||||
expect(() =>
|
||||
T.jsonValue.validateUsingKnownGoodVersion(knownGoodArr, [1, 2, () => {}])
|
||||
).toThrow()
|
||||
|
||||
const knownGoodObj = T.jsonValue.validate({ a: 1 })
|
||||
expect(() => T.jsonValue.validateUsingKnownGoodVersion(knownGoodObj, { a: () => {} })).toThrow()
|
||||
expect(() =>
|
||||
T.jsonValue.validateUsingKnownGoodVersion(knownGoodObj, { a: 1, b: () => {} })
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('[J6] a shape mismatch falls back to a full validation of the new value', () => {
|
||||
const knownGoodArr = T.jsonValue.validate([1])
|
||||
const obj = { a: 1 }
|
||||
expect(T.jsonValue.validateUsingKnownGoodVersion(knownGoodArr, obj)).toBe(obj)
|
||||
|
||||
const knownGoodObj = T.jsonValue.validate({ a: 1 })
|
||||
const arr = [1]
|
||||
expect(T.jsonValue.validateUsingKnownGoodVersion(knownGoodObj, arr)).toBe(arr)
|
||||
|
||||
expect(T.jsonValue.validateUsingKnownGoodVersion(knownGoodObj, 'x')).toBe('x')
|
||||
expect(() => T.jsonValue.validateUsingKnownGoodVersion(knownGoodObj, () => {})).toThrow(
|
||||
'Expected json serializable value, got function'
|
||||
)
|
||||
})
|
||||
})
|
||||
34
packages/validate/src/test/model.test.ts
Normal file
34
packages/validate/src/test/model.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as T from '../lib/validation'
|
||||
|
||||
describe('§14 Models', () => {
|
||||
it('[M1] model prefixes the model name onto validation failures', () => {
|
||||
const shape = T.model('shape', T.object({ id: T.string, x: T.number, y: T.number }))
|
||||
|
||||
const value = { id: 'abc123', x: 132, y: 0 }
|
||||
expect(shape.validate(value)).toBe(value)
|
||||
|
||||
expect(() => shape.validate({ id: 'abc123', x: 132, y: NaN })).toThrow(
|
||||
'At shape.y: Expected a number, got NaN'
|
||||
)
|
||||
expect(() =>
|
||||
T.model(
|
||||
'shape',
|
||||
T.object({ id: T.string, color: T.setEnum(new Set(['red', 'green', 'blue'])) })
|
||||
).validate({ id: 'abc13', color: 'rubbish' })
|
||||
).toThrow('At shape.color: Expected "red" or "green" or "blue", got rubbish')
|
||||
})
|
||||
|
||||
it('[M2] known-good validation delegates to the inner validator with the same prefixing', () => {
|
||||
const user = T.model('user', T.object({ id: T.string, n: T.number }))
|
||||
const knownGood = user.validate({ id: 'x', n: 1 })
|
||||
|
||||
expect(user.validateUsingKnownGoodVersion(knownGood, { id: 'x', n: 1 })).toBe(knownGood)
|
||||
|
||||
const next = { id: 'x', n: 2 }
|
||||
expect(user.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
||||
|
||||
expect(() => user.validateUsingKnownGoodVersion(knownGood, { id: 'x', n: 'bad' })).toThrow(
|
||||
'At user.n: Expected number, got a string'
|
||||
)
|
||||
})
|
||||
})
|
||||
75
packages/validate/src/test/nullable-optional.test.ts
Normal file
75
packages/validate/src/test/nullable-optional.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as T from '../lib/validation'
|
||||
|
||||
describe('§5 optional and nullable', () => {
|
||||
it('[NO1] nullable accepts null and passes everything else to the inner validator', () => {
|
||||
const validator = T.nullable(T.string)
|
||||
expect(validator.validate(null)).toBe(null)
|
||||
expect(validator.validate('hello')).toBe('hello')
|
||||
expect(() => validator.validate(undefined)).toThrow('Expected string, got undefined')
|
||||
expect(() => validator.validate(5)).toThrow('Expected string, got a number')
|
||||
})
|
||||
|
||||
it('[NO1] the .nullable() method behaves like T.nullable', () => {
|
||||
expect(T.string.nullable().validate(null)).toBe(null)
|
||||
expect(T.string.nullable().validate('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('[NO2] optional accepts undefined and passes everything else to the inner validator', () => {
|
||||
const validator = T.optional(T.string)
|
||||
expect(validator.validate(undefined)).toBe(undefined)
|
||||
expect(validator.validate('hello')).toBe('hello')
|
||||
expect(() => validator.validate(null)).toThrow('Expected string, got null')
|
||||
})
|
||||
|
||||
it('[NO2] the .optional() method behaves like T.optional', () => {
|
||||
expect(T.string.optional().validate(undefined)).toBe(undefined)
|
||||
expect(T.string.optional().validate('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('[NO3] known-good validation short-circuits null/undefined new values', () => {
|
||||
let calls = 0
|
||||
const counting = new T.Validator<string>((value) => {
|
||||
calls++
|
||||
return T.string.validate(value)
|
||||
})
|
||||
|
||||
expect(T.optional(counting).validateUsingKnownGoodVersion('a', undefined)).toBe(undefined)
|
||||
expect(T.nullable(counting).validateUsingKnownGoodVersion('a', null)).toBe(null)
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('[NO3] a null/undefined known-good value forces a full inner validate', () => {
|
||||
expect(T.string.optional().validateUsingKnownGoodVersion(undefined, 'a')).toBe('a')
|
||||
expect(() => T.string.optional().validateUsingKnownGoodVersion(undefined, 5)).toThrow(
|
||||
'Expected string, got a number'
|
||||
)
|
||||
expect(T.string.nullable().validateUsingKnownGoodVersion(null, 'a')).toBe('a')
|
||||
expect(() => T.string.nullable().validateUsingKnownGoodVersion(null, 5)).toThrow(
|
||||
'Expected string, got a number'
|
||||
)
|
||||
})
|
||||
|
||||
it('[NO3] otherwise the inner known-good path is used, preserving identity', () => {
|
||||
const validator = T.object({ a: T.number }).optional()
|
||||
const knownGood = validator.validate({ a: 1 })
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, { a: 1 })).toBe(knownGood)
|
||||
|
||||
const next = { a: 2 }
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
||||
})
|
||||
|
||||
it('[NO4] optional/nullable wrappers keep transforming validators exempt from the dev same-value check', () => {
|
||||
expect(
|
||||
T.string
|
||||
.refine((s) => s.toUpperCase())
|
||||
.optional()
|
||||
.validate('a')
|
||||
).toBe('A')
|
||||
expect(
|
||||
T.string
|
||||
.refine((s) => s.toUpperCase())
|
||||
.nullable()
|
||||
.validate('a')
|
||||
).toBe('A')
|
||||
})
|
||||
})
|
||||
114
packages/validate/src/test/numbers.test.ts
Normal file
114
packages/validate/src/test/numbers.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as T from '../lib/validation'
|
||||
|
||||
describe('§7 Numbers', () => {
|
||||
it('[N1] number accepts finite numbers and rejects everything else with specific messages', () => {
|
||||
expect(T.number.validate(42)).toBe(42)
|
||||
expect(T.number.validate(-3.14)).toBe(-3.14)
|
||||
expect(T.number.validate(0)).toBe(0)
|
||||
|
||||
expect(() => T.number.validate('42')).toThrow('Expected number, got a string')
|
||||
expect(() => T.number.validate(NaN)).toThrow('Expected a number, got NaN')
|
||||
expect(() => T.number.validate(Infinity)).toThrow('Expected a finite number, got Infinity')
|
||||
expect(() => T.number.validate(-Infinity)).toThrow('Expected a finite number, got -Infinity')
|
||||
})
|
||||
|
||||
it('[N2] positiveNumber accepts zero and positive finite numbers', () => {
|
||||
expect(T.positiveNumber.validate(29.99)).toBe(29.99)
|
||||
expect(T.positiveNumber.validate(0)).toBe(0)
|
||||
|
||||
expect(() => T.positiveNumber.validate(-1)).toThrow('Expected a positive number, got -1')
|
||||
expect(() => T.positiveNumber.validate(-Infinity)).toThrow(
|
||||
'Expected a positive number, got -Infinity'
|
||||
)
|
||||
expect(() => T.positiveNumber.validate(Infinity)).toThrow(
|
||||
'Expected a finite number, got Infinity'
|
||||
)
|
||||
expect(() => T.positiveNumber.validate(NaN)).toThrow('Expected a number, got NaN')
|
||||
expect(() => T.positiveNumber.validate('1')).toThrow('Expected number, got a string')
|
||||
})
|
||||
|
||||
it('[N3] nonZeroNumber accepts only positive finite numbers', () => {
|
||||
expect(T.nonZeroNumber.validate(0.01)).toBe(0.01)
|
||||
|
||||
expect(() => T.nonZeroNumber.validate(0)).toThrow('Expected a non-zero positive number, got 0')
|
||||
expect(() => T.nonZeroNumber.validate(-5)).toThrow(
|
||||
'Expected a non-zero positive number, got -5'
|
||||
)
|
||||
expect(() => T.nonZeroNumber.validate(-Infinity)).toThrow(
|
||||
'Expected a non-zero positive number, got -Infinity'
|
||||
)
|
||||
})
|
||||
|
||||
it('[N4] nonZeroFiniteNumber accepts non-zero finite numbers including negatives', () => {
|
||||
expect(T.nonZeroFiniteNumber.validate(-1.5)).toBe(-1.5)
|
||||
expect(T.nonZeroFiniteNumber.validate(2)).toBe(2)
|
||||
|
||||
expect(() => T.nonZeroFiniteNumber.validate(0)).toThrow('Expected a non-zero number, got 0')
|
||||
expect(() => T.nonZeroFiniteNumber.validate(Infinity)).toThrow(
|
||||
'Expected a finite number, got Infinity'
|
||||
)
|
||||
})
|
||||
|
||||
it('[N5] unitInterval accepts finite numbers in [0, 1]', () => {
|
||||
expect(T.unitInterval.validate(0)).toBe(0)
|
||||
expect(T.unitInterval.validate(0.5)).toBe(0.5)
|
||||
expect(T.unitInterval.validate(1)).toBe(1)
|
||||
|
||||
expect(() => T.unitInterval.validate(1.5)).toThrow('Expected a number between 0 and 1, got 1.5')
|
||||
expect(() => T.unitInterval.validate(-0.1)).toThrow(
|
||||
'Expected a number between 0 and 1, got -0.1'
|
||||
)
|
||||
expect(() => T.unitInterval.validate(Infinity)).toThrow(
|
||||
'Expected a number between 0 and 1, got Infinity'
|
||||
)
|
||||
expect(() => T.unitInterval.validate(NaN)).toThrow('Expected a number, got NaN')
|
||||
})
|
||||
|
||||
it('[N6] integer accepts whole finite numbers including negatives', () => {
|
||||
expect(T.integer.validate(42)).toBe(42)
|
||||
expect(T.integer.validate(-5)).toBe(-5)
|
||||
expect(T.integer.validate(0)).toBe(0)
|
||||
|
||||
expect(() => T.integer.validate(3.14)).toThrow('Expected an integer, got 3.14')
|
||||
expect(() => T.integer.validate(NaN)).toThrow('Expected a number, got NaN')
|
||||
expect(() => T.integer.validate(Infinity)).toThrow('Expected a finite number, got Infinity')
|
||||
expect(() => T.integer.validate('1')).toThrow('Expected number, got a string')
|
||||
})
|
||||
|
||||
it('[N7] positiveInteger accepts zero and positive integers', () => {
|
||||
expect(T.positiveInteger.validate(5)).toBe(5)
|
||||
expect(T.positiveInteger.validate(0)).toBe(0)
|
||||
|
||||
expect(() => T.positiveInteger.validate(-1)).toThrow('Expected a positive integer, got -1')
|
||||
expect(() => T.positiveInteger.validate(3.14)).toThrow('Expected an integer, got 3.14')
|
||||
})
|
||||
|
||||
it('[N7] positiveInteger reports any negative number as not a positive integer, even fractions', () => {
|
||||
expect(() => T.positiveInteger.validate(-1.5)).toThrow('Expected a positive integer, got -1.5')
|
||||
})
|
||||
|
||||
it('[N8] nonZeroInteger accepts only positive integers', () => {
|
||||
expect(T.nonZeroInteger.validate(1)).toBe(1)
|
||||
|
||||
expect(() => T.nonZeroInteger.validate(0)).toThrow(
|
||||
'Expected a non-zero positive integer, got 0'
|
||||
)
|
||||
expect(() => T.nonZeroInteger.validate(-5)).toThrow(
|
||||
'Expected a non-zero positive integer, got -5'
|
||||
)
|
||||
expect(() => T.nonZeroInteger.validate(0.5)).toThrow('Expected an integer, got 0.5')
|
||||
expect(() => T.nonZeroInteger.validate(-0.5)).toThrow(
|
||||
'Expected a non-zero positive integer, got -0.5'
|
||||
)
|
||||
})
|
||||
|
||||
it('[N9] negative zero counts as zero and as a non-negative integer', () => {
|
||||
expect(T.number.validate(-0)).toBe(-0)
|
||||
expect(T.integer.validate(-0)).toBe(-0)
|
||||
expect(T.positiveNumber.validate(-0)).toBe(-0)
|
||||
expect(T.positiveInteger.validate(-0)).toBe(-0)
|
||||
|
||||
expect(() => T.nonZeroNumber.validate(-0)).toThrow('Expected a non-zero positive number, got 0')
|
||||
expect(() => T.nonZeroFiniteNumber.validate(-0)).toThrow('Expected a non-zero number, got 0')
|
||||
})
|
||||
})
|
||||
137
packages/validate/src/test/objects.test.ts
Normal file
137
packages/validate/src/test/objects.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import * as T from '../lib/validation'
|
||||
|
||||
describe('§10 Objects', () => {
|
||||
it('[O1] object validates each configured property, prefixing the property name', () => {
|
||||
const validator = T.object({ name: T.string, age: T.number })
|
||||
const value = { name: 'Alice', age: 25 }
|
||||
expect(validator.validate(value)).toBe(value)
|
||||
|
||||
expect(() => validator.validate({ name: 'Alice', age: 'old' })).toThrow(
|
||||
'At age: Expected number, got a string'
|
||||
)
|
||||
})
|
||||
|
||||
it('[O1] a missing property is validated as undefined', () => {
|
||||
const validator = T.object({ name: T.string, age: T.number })
|
||||
expect(() => validator.validate({ name: 'Alice' })).toThrow(
|
||||
'At age: Expected number, got undefined'
|
||||
)
|
||||
})
|
||||
|
||||
it('[O2] unknown properties are rejected', () => {
|
||||
expect(() => T.object({ moo: T.literal('cow') }).validate({ moo: 'cow', cow: 'moo' })).toThrow(
|
||||
'At cow: Unexpected property'
|
||||
)
|
||||
})
|
||||
|
||||
it('[O3] allowUnknownProperties returns a new validator accepting unvalidated extras', () => {
|
||||
const strict = T.object({ name: T.string })
|
||||
const loose = strict.allowUnknownProperties()
|
||||
|
||||
const value = { name: 'Alice', extra: () => {} }
|
||||
expect(loose.validate(value)).toBe(value)
|
||||
|
||||
// the original validator is unchanged
|
||||
expect(() => strict.validate({ name: 'Alice', extra: 1 })).toThrow(
|
||||
'At extra: Unexpected property'
|
||||
)
|
||||
})
|
||||
|
||||
it('[O4] non-objects and null are rejected', () => {
|
||||
const validator = T.object({ a: T.number })
|
||||
expect(() => validator.validate(null)).toThrow('Expected object, got null')
|
||||
expect(() => validator.validate('x')).toThrow('Expected object, got a string')
|
||||
expect(() => validator.validate(undefined)).toThrow('Expected object, got undefined')
|
||||
})
|
||||
|
||||
it('[O4] arrays pass the object check', () => {
|
||||
const empty: never[] = []
|
||||
expect(T.object({}).validate(empty)).toBe(empty)
|
||||
expect(() => T.object({}).validate([1])).toThrow('At 0: Unexpected property')
|
||||
expect(() => T.object({ a: T.number }).validate([])).toThrow(
|
||||
'At a: Expected number, got undefined'
|
||||
)
|
||||
})
|
||||
|
||||
it('[O5] extend merges configs, with extension keys overriding', () => {
|
||||
const base = T.object({ a: T.number, b: T.number })
|
||||
const extended = base.extend({ b: T.string as any as T.Validatable<number>, c: T.boolean })
|
||||
|
||||
const value = { a: 1, b: 'x', c: true }
|
||||
expect(extended.validate(value as any)).toBe(value)
|
||||
expect(() => extended.validate({ a: 1, b: 2, c: true } as any)).toThrow(
|
||||
'At b: Expected string, got a number'
|
||||
)
|
||||
|
||||
// the base validator is unchanged
|
||||
expect(base.validate({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
|
||||
it('[O5] the extended validator rejects unknown properties even when the receiver allowed them', () => {
|
||||
const validator = T.object({ a: T.number }).allowUnknownProperties().extend({ b: T.string })
|
||||
expect(() => validator.validate({ a: 1, b: 'x', c: true })).toThrow('At c: Unexpected property')
|
||||
})
|
||||
|
||||
it('[O6] known-good validation returns the known-good object when nothing changed', () => {
|
||||
const validator = T.object({ a: T.number, nested: T.object({ b: T.string }) })
|
||||
const knownGood = validator.validate({ a: 1, nested: { b: 'x' } })
|
||||
|
||||
// new outer and inner objects, structurally equal
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, { a: 1, nested: { b: 'x' } })).toBe(
|
||||
knownGood
|
||||
)
|
||||
})
|
||||
|
||||
it('[O6] known-good validation returns the new object when a property changed, revalidating only changes', () => {
|
||||
let calls = 0
|
||||
const counting = new T.Validator<number>((value) => {
|
||||
calls++
|
||||
return T.number.validate(value)
|
||||
})
|
||||
const validator = T.object({ a: counting, b: counting })
|
||||
const knownGood = validator.validate({ a: 1, b: 2 })
|
||||
calls = 0
|
||||
|
||||
const next = { a: 1, b: 3 }
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
||||
expect(calls).toBe(1)
|
||||
|
||||
expect(() => validator.validateUsingKnownGoodVersion(knownGood, { a: 1, b: 'x' })).toThrow(
|
||||
'At b: Expected number, got a string'
|
||||
)
|
||||
})
|
||||
|
||||
it('[O6] known-good validation rejects non-objects and added unknown properties', () => {
|
||||
const validator = T.object({ a: T.number })
|
||||
const knownGood = validator.validate({ a: 1 })
|
||||
expect(() => validator.validateUsingKnownGoodVersion(knownGood, 'x')).toThrow(
|
||||
'Expected object, got a string'
|
||||
)
|
||||
expect(() => validator.validateUsingKnownGoodVersion(knownGood, { a: 1, b: 2 })).toThrow(
|
||||
'At b: Unexpected property'
|
||||
)
|
||||
})
|
||||
|
||||
it('[O8] known-good validation detects changes to unknown properties', () => {
|
||||
const validator = T.object({ a: T.number }).allowUnknownProperties()
|
||||
|
||||
const knownGood = validator.validate({ a: 1, extra: 1 })
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, { a: 1, extra: 1 })).toBe(knownGood)
|
||||
|
||||
const changed = { a: 1, extra: 2 }
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, changed)).toBe(changed)
|
||||
|
||||
const added = { a: 1, extra: 1, more: true }
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, added)).toBe(added)
|
||||
|
||||
const removed = { a: 1 }
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, removed)).toBe(removed)
|
||||
})
|
||||
|
||||
it('[O7] known-good validation detects removed keys', () => {
|
||||
const validator = T.object({ a: T.number, b: T.number.optional() })
|
||||
const knownGood = validator.validate({ a: 1, b: 2 })
|
||||
const next = { a: 1 }
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
||||
})
|
||||
})
|
||||
13
packages/validate/src/test/or.test.ts
Normal file
13
packages/validate/src/test/or.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as T from '../lib/validation'
|
||||
|
||||
describe('§17 Either-of', () => {
|
||||
it('[OR1] returns the first validator’s result when it passes, else the second’s', () => {
|
||||
const stringOrNumber = T.or(T.string, T.number)
|
||||
expect(stringOrNumber.validate('hello')).toBe('hello')
|
||||
expect(stringOrNumber.validate(42)).toBe(42)
|
||||
})
|
||||
|
||||
it('[OR2] when both fail, the second validator’s error propagates', () => {
|
||||
expect(() => T.or(T.string, T.number).validate(true)).toThrow('Expected number, got a boolean')
|
||||
})
|
||||
})
|
||||
67
packages/validate/src/test/primitives.test.ts
Normal file
67
packages/validate/src/test/primitives.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as T from '../lib/validation'
|
||||
|
||||
describe('§6 Primitives', () => {
|
||||
it('[P1] string, boolean, and bigint validate by typeof', () => {
|
||||
expect(T.string.validate('hello')).toBe('hello')
|
||||
expect(T.string.validate('')).toBe('')
|
||||
expect(() => T.string.validate(123)).toThrow('Expected string, got a number')
|
||||
|
||||
expect(T.boolean.validate(true)).toBe(true)
|
||||
expect(T.boolean.validate(false)).toBe(false)
|
||||
expect(() => T.boolean.validate('true')).toThrow('Expected boolean, got a string')
|
||||
|
||||
expect(T.bigint.validate(123n)).toBe(123n)
|
||||
expect(() => T.bigint.validate(123)).toThrow('Expected bigint, got a number')
|
||||
})
|
||||
|
||||
it('[P2] type-mismatch messages describe the actual value', () => {
|
||||
expect(() => T.string.validate(null)).toThrow('Expected string, got null')
|
||||
expect(() => T.string.validate(undefined)).toThrow('Expected string, got undefined')
|
||||
expect(() => T.string.validate([1])).toThrow('Expected string, got an array')
|
||||
expect(() => T.string.validate({})).toThrow('Expected string, got an object')
|
||||
expect(() => T.string.validate(1)).toThrow('Expected string, got a number')
|
||||
expect(() => T.string.validate(true)).toThrow('Expected string, got a boolean')
|
||||
expect(() => T.string.validate(1n)).toThrow('Expected string, got a bigint')
|
||||
expect(() => T.string.validate(() => {})).toThrow('Expected string, got a function')
|
||||
expect(() => T.string.validate(Symbol('s'))).toThrow('Expected string, got a symbol')
|
||||
})
|
||||
|
||||
it('[P3] unknown and any accept every value as-is', () => {
|
||||
const obj = { a: 1 }
|
||||
expect(T.unknown.validate(obj)).toBe(obj)
|
||||
expect(T.unknown.validate(undefined)).toBe(undefined)
|
||||
expect(T.unknown.validate(null)).toBe(null)
|
||||
expect(T.any.validate(obj)).toBe(obj)
|
||||
expect(T.any.validate(undefined)).toBe(undefined)
|
||||
})
|
||||
|
||||
it('[P4] literal accepts exactly the expected value', () => {
|
||||
expect(T.literal('active').validate('active')).toBe('active')
|
||||
expect(T.literal(1).validate(1)).toBe(1)
|
||||
expect(T.literal(true).validate(true)).toBe(true)
|
||||
|
||||
expect(() => T.literal('a').validate('b')).toThrow('Expected a, got "b"')
|
||||
expect(() => T.literal(1).validate(2)).toThrow('Expected 1, got 2')
|
||||
expect(() => T.literal(1).validate(undefined)).toThrow('Expected 1, got undefined')
|
||||
})
|
||||
|
||||
it('[P5] array accepts any array without validating items', () => {
|
||||
const arr = [1, 'two', null, () => {}]
|
||||
expect(T.array.validate(arr)).toBe(arr)
|
||||
expect(() => T.array.validate('not array')).toThrow('Expected an array, got a string')
|
||||
expect(() => T.array.validate({})).toThrow('Expected an array, got an object')
|
||||
})
|
||||
|
||||
it('[P6] unknownObject accepts any non-null object and rejects null and primitives', () => {
|
||||
const obj = { any: 'properties' }
|
||||
expect(T.unknownObject.validate(obj)).toBe(obj)
|
||||
expect(() => T.unknownObject.validate(null)).toThrow('Expected object, got null')
|
||||
expect(() => T.unknownObject.validate('x')).toThrow('Expected object, got a string')
|
||||
expect(() => T.unknownObject.validate(5)).toThrow('Expected object, got a number')
|
||||
})
|
||||
|
||||
it('[P6] unknownObject accepts arrays', () => {
|
||||
const arr = [1, 2, 3]
|
||||
expect(T.unknownObject.validate(arr)).toBe(arr)
|
||||
})
|
||||
})
|
||||
82
packages/validate/src/test/refine-check.test.ts
Normal file
82
packages/validate/src/test/refine-check.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import * as T from '../lib/validation'
|
||||
import { ValidationError } from '../lib/validation'
|
||||
|
||||
describe('§4 Refinement: refine and check', () => {
|
||||
it('[RC1] refine transforms the validated value, including to a new type', () => {
|
||||
const stringToNumber = T.string.refine((str) => parseInt(str, 10))
|
||||
expect(stringToNumber.validate('42')).toBe(42)
|
||||
|
||||
const prefixedString = T.string.refine((str) =>
|
||||
str.startsWith('prefix:') ? str : `prefix:${str}`
|
||||
)
|
||||
expect(prefixedString.validate('test')).toBe('prefix:test')
|
||||
expect(prefixedString.validate('prefix:existing')).toBe('prefix:existing')
|
||||
})
|
||||
|
||||
it('[RC1] a ValidationError thrown by the refinement propagates with path context', () => {
|
||||
const stringToNumber = T.string.refine((str) => {
|
||||
const num = parseInt(str, 10)
|
||||
if (isNaN(num)) throw new ValidationError('Invalid number format')
|
||||
return num
|
||||
})
|
||||
|
||||
expect(() => stringToNumber.validate('not-a-number')).toThrow('Invalid number format')
|
||||
|
||||
const nested = T.object({ count: stringToNumber })
|
||||
expect(() => nested.validate({ count: 'nope' })).toThrow('At count: Invalid number format')
|
||||
})
|
||||
|
||||
it('[RC2] known-good validation skips the refinement when the base reports no change', () => {
|
||||
let calls = 0
|
||||
const validator = T.arrayOf(T.number).refine((arr) => {
|
||||
calls++
|
||||
return arr
|
||||
})
|
||||
|
||||
const knownGood = validator.validate([1, 2])
|
||||
expect(calls).toBe(1)
|
||||
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, [1, 2])).toBe(knownGood)
|
||||
expect(calls).toBe(1)
|
||||
|
||||
const next = [1, 3]
|
||||
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it('[RC3] known-good validation accepts a new value Object.is-equal to the previous output without input validation', () => {
|
||||
const stringToNumber = T.string.refine((str) => parseInt(str, 10))
|
||||
// 42 is not a valid input (not a string), but it equals the previous output.
|
||||
expect(stringToNumber.validateUsingKnownGoodVersion(42, 42)).toBe(42)
|
||||
})
|
||||
|
||||
it('[RC4] an unnamed check passes the value through and adds no path segment', () => {
|
||||
const evenNumber = T.number.check((value) => {
|
||||
if (value % 2 !== 0) throw new ValidationError('Expected even number')
|
||||
})
|
||||
|
||||
expect(evenNumber.validate(4)).toBe(4)
|
||||
expect(evenNumber.validate(0)).toBe(0)
|
||||
|
||||
try {
|
||||
evenNumber.validate(3)
|
||||
throw new Error('should have thrown')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ValidationError)
|
||||
expect((error as ValidationError).rawMessage).toBe('Expected even number')
|
||||
expect((error as ValidationError).path).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('[RC5] a named check prefixes failures with a (check name) segment', () => {
|
||||
const positive = T.number.check('positive', (value) => {
|
||||
if (value <= 0) throw new ValidationError('Must be positive')
|
||||
})
|
||||
|
||||
expect(positive.validate(5)).toBe(5)
|
||||
expect(() => positive.validate(-1)).toThrow('At (check positive): Must be positive')
|
||||
|
||||
const nested = T.object({ x: positive })
|
||||
expect(() => nested.validate({ x: -1 })).toThrow('At x(check positive): Must be positive')
|
||||
})
|
||||
})
|
||||
147
packages/validate/src/test/unions.test.ts
Normal file
147
packages/validate/src/test/unions.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import * as T from '../lib/validation'
|
||||
|
||||
const cat = T.object({ type: T.literal('cat'), id: T.string, meow: T.boolean })
|
||||
const dog = T.object({ type: T.literal('dog'), id: T.string, bark: T.boolean })
|
||||
const animal = T.union('type', { cat, dog })
|
||||
|
||||
describe('§12 Discriminated unions', () => {
|
||||
it('[U1] selects the variant by discriminator and returns the input on success', () => {
|
||||
const value = { type: 'cat', id: 'abc123', meow: true }
|
||||
expect(animal.validate(value)).toBe(value)
|
||||
})
|
||||
|
||||
it('[U2] rejects non-object inputs', () => {
|
||||
expect(() => animal.validate('cat')).toThrow('Expected an object, got a string')
|
||||
expect(() => animal.validate(null)).toThrow('Expected an object, got null')
|
||||
})
|
||||
|
||||
it('[U3] rejects a missing or non-string discriminator', () => {
|
||||
expect(() => animal.validate({})).toThrow('Expected a string for key "type", got undefined')
|
||||
expect(() => animal.validate({ type: 1 })).toThrow(
|
||||
'Expected a string for key "type", got a number'
|
||||
)
|
||||
})
|
||||
|
||||
it('[U4] rejects unmatched variants, listing the known ones at the discriminator path', () => {
|
||||
expect(() =>
|
||||
T.object({ animal }).validate({ animal: { type: 'cow', moo: true, id: 'abc123' } })
|
||||
).toThrow('At animal.type: Expected one of "cat" or "dog", got "cow"')
|
||||
})
|
||||
|
||||
it('[U5] prefixes variant validation failures with the (key = variant) segment', () => {
|
||||
expect(() =>
|
||||
T.object({ animal }).validate({ animal: { type: 'cat', meow: 'yes', id: 'abc123' } })
|
||||
).toThrow('At animal(type = cat).meow: Expected boolean, got a string')
|
||||
|
||||
expect(() =>
|
||||
T.model('animal', animal).validate({ type: 'cat', moo: true, id: 'abc123' })
|
||||
).toThrow('At animal(type = cat).meow: Expected boolean, got undefined')
|
||||
})
|
||||
|
||||
it('[U6] validateUnknownVariants handles unmatched variants with the handler result', () => {
|
||||
let seenVariant = ''
|
||||
const lenient = animal.validateUnknownVariants((value, variant) => {
|
||||
seenVariant = variant
|
||||
return value as any
|
||||
})
|
||||
|
||||
const unknownValue = { type: 'cow', moo: true }
|
||||
expect(lenient.validate(unknownValue)).toBe(unknownValue)
|
||||
expect(seenVariant).toBe('cow')
|
||||
|
||||
// known variants still validate normally
|
||||
expect(() => lenient.validate({ type: 'cat', id: 'x', meow: 'yes' })).toThrow(
|
||||
'At (type = cat).meow: Expected boolean, got a string'
|
||||
)
|
||||
})
|
||||
|
||||
it('[U6] a handler returning a new object trips the dev same-value check on the validate path', () => {
|
||||
const lenient = animal.validateUnknownVariants((value) => ({ ...value }) as any)
|
||||
expect(() => lenient.validate({ type: 'cow' })).toThrow(
|
||||
'Validator functions must return the same value they were passed'
|
||||
)
|
||||
|
||||
// the known-good path does not run that check
|
||||
const out = lenient.validateUsingKnownGoodVersion({ type: 'cow' } as any, { type: 'emu' })
|
||||
expect(out).toEqual({ type: 'emu' })
|
||||
})
|
||||
|
||||
it('[U7] known-good validation with an unchanged discriminator preserves identity', () => {
|
||||
const knownGood = animal.validate({ type: 'cat', id: 'x', meow: true })
|
||||
expect(
|
||||
animal.validateUsingKnownGoodVersion(knownGood, { type: 'cat', id: 'x', meow: true })
|
||||
).toBe(knownGood)
|
||||
|
||||
const next = { type: 'cat', id: 'x', meow: false }
|
||||
expect(animal.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
||||
expect(() =>
|
||||
animal.validateUsingKnownGoodVersion(knownGood, { type: 'cat', id: 'x', meow: 'no' })
|
||||
).toThrow('At (type = cat).meow: Expected boolean, got a string')
|
||||
})
|
||||
|
||||
it('[U7] a changed discriminator falls back to a full validation of the new variant', () => {
|
||||
const knownGood = animal.validate({ type: 'cat', id: 'x', meow: true })
|
||||
const next = { type: 'dog', id: 'x', bark: true }
|
||||
expect(animal.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
||||
expect(() => animal.validateUsingKnownGoodVersion(knownGood, { type: 'dog', id: 'x' })).toThrow(
|
||||
'At (type = dog).bark: Expected boolean, got undefined'
|
||||
)
|
||||
})
|
||||
|
||||
it('[U7] known-good validation rejects a non-object on either side', () => {
|
||||
const knownGood = animal.validate({ type: 'cat', id: 'x', meow: true })
|
||||
expect(() => animal.validateUsingKnownGoodVersion(knownGood, 'x')).toThrow(
|
||||
'Expected an object, got a string'
|
||||
)
|
||||
expect(() =>
|
||||
animal.validateUsingKnownGoodVersion(null as any, { type: 'cat', id: 'x', meow: true })
|
||||
).toThrow('Expected an object, got null')
|
||||
})
|
||||
|
||||
describe('numberUnion', () => {
|
||||
const versioned = T.numberUnion('version', {
|
||||
1: T.object({ version: T.literal(1), data: T.string }),
|
||||
2: T.object({ version: T.literal(2), data: T.string }),
|
||||
})
|
||||
|
||||
it('[U8] validates variants keyed by finite number discriminators', () => {
|
||||
const v1 = { version: 1, data: 'hello' }
|
||||
const v2 = { version: 2, data: 'world' }
|
||||
expect(versioned.validate(v1)).toBe(v1)
|
||||
expect(versioned.validate(v2)).toBe(v2)
|
||||
})
|
||||
|
||||
it('[U8] rejects Infinity, -Infinity, NaN, and non-numeric discriminators', () => {
|
||||
expect(() => versioned.validate({ version: Infinity, data: 'x' })).toThrow(
|
||||
'Expected a number for key "version", got "Infinity"'
|
||||
)
|
||||
expect(() => versioned.validate({ version: -Infinity, data: 'x' })).toThrow(
|
||||
'Expected a number for key "version", got "-Infinity"'
|
||||
)
|
||||
expect(() => versioned.validate({ version: NaN, data: 'x' })).toThrow(
|
||||
'Expected a number for key "version", got "NaN"'
|
||||
)
|
||||
expect(() => versioned.validate({ version: 'two', data: 'x' })).toThrow(
|
||||
'Expected a number for key "version", got "two"'
|
||||
)
|
||||
expect(() => versioned.validate({ data: 'x' })).toThrow(
|
||||
'Expected a number for key "version", got "undefined"'
|
||||
)
|
||||
})
|
||||
|
||||
it('[U9] looks the variant up by string coercion, so string-numeric discriminators select the variant', () => {
|
||||
expect(() => versioned.validate({ version: '1', data: 'x' })).toThrow(
|
||||
'At (version = 1).version: Expected 1, got "1"'
|
||||
)
|
||||
})
|
||||
|
||||
it('[U9] unmatched finite numbers are rejected as unknown variants', () => {
|
||||
expect(() => versioned.validate({ version: 1.5, data: 'x' })).toThrow(
|
||||
'At version: Expected one of "1" or "2", got 1.5'
|
||||
)
|
||||
expect(() => versioned.validate({ version: 3, data: 'x' })).toThrow(
|
||||
'At version: Expected one of "1" or "2", got 3'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
59
packages/validate/src/test/urls.test.ts
Normal file
59
packages/validate/src/test/urls.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as T from '../lib/validation'
|
||||
|
||||
describe('§15 URL validators', () => {
|
||||
it('[UR1] linkUrl accepts the empty string and http/https/mailto urls', () => {
|
||||
expect(T.linkUrl.validate('')).toBe('')
|
||||
expect(T.linkUrl.validate('https://example.com')).toBe('https://example.com')
|
||||
expect(T.linkUrl.validate('http://example.com')).toBe('http://example.com')
|
||||
expect(T.linkUrl.validate('mailto:user@example.com')).toBe('mailto:user@example.com')
|
||||
|
||||
expect(() => T.linkUrl.validate('javascript:alert(1)')).toThrow(
|
||||
'Expected a valid url, got "javascript:alert(1)" (invalid protocol)'
|
||||
)
|
||||
expect(() => T.linkUrl.validate('asset:abc')).toThrow('(invalid protocol)')
|
||||
expect(() => T.linkUrl.validate('not a url')).toThrow('Expected a valid url, got "not a url"')
|
||||
})
|
||||
|
||||
it('[UR2] srcUrl accepts the empty string and http/https/data/asset urls', () => {
|
||||
expect(T.srcUrl.validate('')).toBe('')
|
||||
expect(T.srcUrl.validate('https://example.com/image.png')).toBe('https://example.com/image.png')
|
||||
expect(T.srcUrl.validate('data:image/png;base64,iVBORw0')).toBe('data:image/png;base64,iVBORw0')
|
||||
expect(T.srcUrl.validate('asset:abc123')).toBe('asset:abc123')
|
||||
|
||||
expect(() => T.srcUrl.validate('mailto:user@example.com')).toThrow('(invalid protocol)')
|
||||
expect(() => T.srcUrl.validate('javascript:alert(1)')).toThrow('(invalid protocol)')
|
||||
})
|
||||
|
||||
it('[UR3] httpUrl accepts the empty string and only http/https urls', () => {
|
||||
expect(T.httpUrl.validate('')).toBe('')
|
||||
expect(T.httpUrl.validate('https://api.example.com')).toBe('https://api.example.com')
|
||||
expect(T.httpUrl.validate('http://localhost:3000')).toBe('http://localhost:3000')
|
||||
|
||||
expect(() => T.httpUrl.validate('ftp://files.example.com')).toThrow('(invalid protocol)')
|
||||
expect(() => T.httpUrl.validate('mailto:user@example.com')).toThrow('(invalid protocol)')
|
||||
expect(() => T.httpUrl.validate('data:text/plain,hi')).toThrow('(invalid protocol)')
|
||||
})
|
||||
|
||||
it('[UR4] strings starting with / or ./ validate; other relative forms do not', () => {
|
||||
expect(T.linkUrl.validate('/foo')).toBe('/foo')
|
||||
expect(T.linkUrl.validate('./foo')).toBe('./foo')
|
||||
expect(T.srcUrl.validate('/foo')).toBe('/foo')
|
||||
expect(T.srcUrl.validate('./foo')).toBe('./foo')
|
||||
expect(T.httpUrl.validate('/foo')).toBe('/foo')
|
||||
expect(T.httpUrl.validate('./foo')).toBe('./foo')
|
||||
|
||||
expect(() => T.linkUrl.validate('../foo')).toThrow('Expected a valid url, got "../foo"')
|
||||
expect(() => T.linkUrl.validate('foo')).toThrow('Expected a valid url, got "foo"')
|
||||
})
|
||||
|
||||
it('[UR5] protocol matching is case-insensitive', () => {
|
||||
expect(T.linkUrl.validate('HTTP://example.com')).toBe('HTTP://example.com')
|
||||
expect(() => T.linkUrl.validate('JAVASCRIPT:alert(1)')).toThrow('(invalid protocol)')
|
||||
})
|
||||
|
||||
it('[UR6] non-strings are rejected as strings', () => {
|
||||
expect(() => T.linkUrl.validate(5)).toThrow('Expected string, got a number')
|
||||
expect(() => T.srcUrl.validate(null)).toThrow('Expected string, got null')
|
||||
expect(() => T.httpUrl.validate(undefined)).toThrow('Expected string, got undefined')
|
||||
})
|
||||
})
|
||||
378
packages/validate/src/test/validation.fuzz.test.ts
Normal file
378
packages/validate/src/test/validation.fuzz.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { isEqual, mapObjectMapValues } from '@tldraw/utils'
|
||||
import { T, Validator } from '..'
|
||||
|
||||
class RandomSource {
|
||||
private seed: number
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed
|
||||
}
|
||||
|
||||
nextFloat(): number {
|
||||
this.seed = (this.seed * 9301 + 49297) % 233280
|
||||
return this.seed / 233280
|
||||
}
|
||||
|
||||
nextInt(max: number): number {
|
||||
return Math.floor(this.nextFloat() * max)
|
||||
}
|
||||
|
||||
nextIntInRange(min: number, max: number): number {
|
||||
return this.nextInt(max - min) + min
|
||||
}
|
||||
|
||||
nextId(): string {
|
||||
return this.nextInt(Number.MAX_SAFE_INTEGER).toString(36)
|
||||
}
|
||||
|
||||
selectOne<T>(arr: readonly T[]): T {
|
||||
return arr[this.nextInt(arr.length)]
|
||||
}
|
||||
|
||||
choice(probability: number): boolean {
|
||||
return this.nextFloat() < probability
|
||||
}
|
||||
|
||||
executeOne<Result>(
|
||||
_choices: Record<string, (() => Result) | { weight?: number; do(): Result }>
|
||||
): Result {
|
||||
const choices = Object.values(_choices).map((choice) => {
|
||||
if (typeof choice === 'function') {
|
||||
return { weight: 1, do: choice }
|
||||
}
|
||||
return choice
|
||||
})
|
||||
const totalWeight = Object.values(choices).reduce(
|
||||
(total, choice) => total + (choice.weight ?? 1),
|
||||
0
|
||||
)
|
||||
const randomWeight = this.nextInt(totalWeight)
|
||||
let weight = 0
|
||||
for (const choice of Object.values(choices)) {
|
||||
weight += choice.weight ?? 1
|
||||
if (randomWeight < weight) {
|
||||
return choice.do()
|
||||
}
|
||||
}
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
|
||||
nextPropertyName(): string {
|
||||
return this.selectOne(['foo', 'bar', 'baz', 'qux', 'mux', 'bah'])
|
||||
}
|
||||
|
||||
nextJsonValue(): any {
|
||||
return this.executeOne<any>({
|
||||
string: { weight: 1, do: () => this.nextId() },
|
||||
number: { weight: 1, do: () => this.nextFloat() },
|
||||
integer: { weight: 1, do: () => this.nextInt(100) },
|
||||
boolean: { weight: 1, do: () => this.choice(0.5) },
|
||||
null: { weight: 1, do: () => null },
|
||||
array: {
|
||||
weight: 1,
|
||||
do: () => {
|
||||
const numItems = this.nextInt(4)
|
||||
const result = []
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
result.push(this.nextJsonValue())
|
||||
}
|
||||
return result
|
||||
},
|
||||
},
|
||||
object: {
|
||||
weight: 1,
|
||||
do: () => {
|
||||
const numItems = this.nextInt(4)
|
||||
const result = {} as any
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
result[this.nextPropertyName()] = this.nextJsonValue()
|
||||
}
|
||||
return result
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
nextTestType(depth: number): TestType {
|
||||
if (depth >= 3) {
|
||||
return this.selectOne(Object.values(builtinTypes))
|
||||
}
|
||||
return this.executeOne<TestType>({
|
||||
primitive: () => this.selectOne(Object.values(builtinTypes)),
|
||||
array: () => generateArrayType(this, depth),
|
||||
object: () => generateObjectType(this, {}, depth),
|
||||
union: () => generateUnionType(this, depth),
|
||||
dict: () => generateDictType(this, depth),
|
||||
model: () => {
|
||||
const objType = generateObjectType(this, {}, depth)
|
||||
const name = this.nextPropertyName()
|
||||
return {
|
||||
...objType,
|
||||
validator: T.model(name, objType.validator),
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface TestType {
|
||||
validator: T.Validator<any>
|
||||
generateValid(source: RandomSource): any
|
||||
generateInvalid(source: RandomSource): any
|
||||
}
|
||||
const builtinTypes = {
|
||||
string: {
|
||||
validator: T.string,
|
||||
generateValid: (source) => source.selectOne(['a', 'b', 'c', 'd']),
|
||||
generateInvalid: (source) => source.selectOne([5, /regexp/, {}]),
|
||||
},
|
||||
number: {
|
||||
validator: T.number,
|
||||
generateValid: (source) => source.nextInt(5),
|
||||
generateInvalid: (source) => source.selectOne(['a', /num/]),
|
||||
},
|
||||
integer: {
|
||||
validator: T.integer,
|
||||
generateValid: (source) => source.nextInt(5),
|
||||
generateInvalid: (source) => source.selectOne([0.2, '3', 5n, /int/]),
|
||||
},
|
||||
json: {
|
||||
validator: T.jsonValue,
|
||||
generateValid: (source) => source.nextJsonValue(),
|
||||
generateInvalid: (source) => source.selectOne([/regexp/, 343n, { key: /regexp/ }]),
|
||||
},
|
||||
} as const satisfies Record<string, TestType>
|
||||
|
||||
function generateObjectType(
|
||||
source: RandomSource,
|
||||
injectProperties: Record<string, TestType>,
|
||||
depth: number
|
||||
): TestType {
|
||||
const numProperties = source.nextIntInRange(1, 5)
|
||||
const propertyTypes: Record<string, TestType> = {
|
||||
...injectProperties,
|
||||
}
|
||||
const optionalTypes = new Set<string>()
|
||||
const nullableTypes = new Set<string>()
|
||||
for (let i = 0; i < numProperties; i++) {
|
||||
const type = source.nextTestType(depth + 1)
|
||||
const name = source.nextPropertyName()
|
||||
if (source.choice(0.2)) {
|
||||
optionalTypes.add(name)
|
||||
}
|
||||
if (source.choice(0.2)) {
|
||||
nullableTypes.add(name)
|
||||
}
|
||||
let validator = type.validator
|
||||
if (nullableTypes.has(name)) {
|
||||
validator = validator.nullable()
|
||||
}
|
||||
if (optionalTypes.has(name)) {
|
||||
validator = validator.optional()
|
||||
}
|
||||
propertyTypes[name] = { ...type, validator }
|
||||
}
|
||||
|
||||
const generateValid = (source: RandomSource) => {
|
||||
const result = {} as any
|
||||
for (const [name, type] of Object.entries(propertyTypes)) {
|
||||
if (optionalTypes.has(name) && source.choice(0.2)) {
|
||||
continue
|
||||
} else if (nullableTypes.has(name) && source.choice(0.2)) {
|
||||
result[name] = null
|
||||
continue
|
||||
}
|
||||
result[name] = type.generateValid(source)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
validator: T.object(mapObjectMapValues(propertyTypes, (_, { validator }) => validator)),
|
||||
generateValid,
|
||||
generateInvalid: (source) => {
|
||||
return source.executeOne<any>({
|
||||
otherType: () =>
|
||||
source.executeOne<any>({
|
||||
string: () => source.selectOne(['a', 'b', 'c', 'd']),
|
||||
number: () => source.nextInt(5),
|
||||
array: () => [source.nextId(), source.nextFloat()],
|
||||
bool: () => true,
|
||||
}),
|
||||
missingProperty: () => {
|
||||
const val = generateValid(source)
|
||||
const keyToDelete = source.selectOne(
|
||||
Object.keys(val).filter((key) => !optionalTypes.has(key))
|
||||
)
|
||||
if (!keyToDelete) {
|
||||
// no non-optional properties, do a invalid property test instead
|
||||
val[keyToDelete] =
|
||||
propertyTypes[source.selectOne(Object.keys(propertyTypes))].generateInvalid(source)
|
||||
return val
|
||||
}
|
||||
delete val[keyToDelete]
|
||||
return val
|
||||
},
|
||||
extraProperty: () => {
|
||||
const val = generateValid(source)
|
||||
val[source.nextPropertyName() + '_'] = source.nextJsonValue()
|
||||
return val
|
||||
},
|
||||
invalidProperty: () => {
|
||||
const val = generateValid(source)
|
||||
const keyToChange = source.selectOne(Object.keys(propertyTypes))
|
||||
val[keyToChange] = propertyTypes[keyToChange].generateInvalid(source)
|
||||
return val
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateDictType(source: RandomSource, depth: number): TestType {
|
||||
const keyType = builtinTypes.string
|
||||
const keySet = ['a', 'b', 'c', 'd', 'e', 'f'] as const
|
||||
const valueType = source.nextTestType(depth + 1)
|
||||
|
||||
const validator = T.dict(keyType.validator, valueType.validator)
|
||||
|
||||
const generateValid = (source: RandomSource) => {
|
||||
const result = {} as any
|
||||
const numItems = source.nextInt(4)
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
result[source.selectOne(keySet)] = valueType.generateValid(source)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
validator,
|
||||
generateValid,
|
||||
generateInvalid: (source) => {
|
||||
const result = generateValid(source)
|
||||
const key = source.selectOne(Object.keys(result)) ?? source.nextPropertyName()
|
||||
result[key] = valueType.generateInvalid(source)
|
||||
return result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createLiteralType(value: string): TestType {
|
||||
return {
|
||||
validator: T.literal(value),
|
||||
generateValid: () => value,
|
||||
generateInvalid: (source) => source.selectOne(['_invalid_' + value, 2324, {}]),
|
||||
}
|
||||
}
|
||||
|
||||
function generateUnionType(source: RandomSource, depth: number): TestType {
|
||||
const key = source.selectOne(['type', 'name', 'kind'])
|
||||
const numMembers = source.nextIntInRange(1, 4)
|
||||
const members: TestType[] = []
|
||||
const unionMap: Record<string, Validator<any>> = {}
|
||||
for (let i = 0; i < numMembers; i++) {
|
||||
const id = source.nextId()
|
||||
const keyType = createLiteralType(id)
|
||||
const type = generateObjectType(source, { [key]: keyType }, depth + 1)
|
||||
members.push(type)
|
||||
unionMap[id] = type.validator
|
||||
}
|
||||
const validator = T.union(key, unionMap)
|
||||
|
||||
return {
|
||||
validator,
|
||||
generateValid: (source) => {
|
||||
const member = source.selectOne(members)
|
||||
return member.generateValid(source)
|
||||
},
|
||||
generateInvalid(source) {
|
||||
return source.executeOne<any>({
|
||||
otherType: () => source.selectOne(['_invalid_', 2324, {}]),
|
||||
badMember: {
|
||||
weight: 4,
|
||||
do() {
|
||||
const member = source.selectOne(members)
|
||||
return member.generateInvalid(source)
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateArrayType(source: RandomSource, depth: number): TestType {
|
||||
const valueType = source.nextTestType(depth + 1)
|
||||
const validator = T.arrayOf(valueType.validator)
|
||||
const generateValid = (source: RandomSource) => {
|
||||
const result = [] as any[]
|
||||
const numItems = source.nextInt(4)
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
result.push(valueType.generateValid(source))
|
||||
}
|
||||
return result
|
||||
}
|
||||
return {
|
||||
validator,
|
||||
generateValid,
|
||||
generateInvalid: (source) => {
|
||||
return source.executeOne<any>({
|
||||
otherType: () =>
|
||||
source.executeOne<any>({
|
||||
string: () => source.nextId(),
|
||||
number: () => source.nextInt(100),
|
||||
object: () => ({ key: source.nextId() }),
|
||||
}),
|
||||
invalidItem: () => {
|
||||
const val = generateValid(source)
|
||||
if (val.length === 0) {
|
||||
return [valueType.generateInvalid(source)]
|
||||
}
|
||||
const indexToChange = source.nextInt(val.length)
|
||||
val[indexToChange] = valueType.generateInvalid(source)
|
||||
return val
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function runTest(seed: number) {
|
||||
test(`fuzz test with seed ${seed}`, () => {
|
||||
const source = new RandomSource(seed)
|
||||
const type = source.nextTestType(0)
|
||||
const oldValid = type.generateValid(source)
|
||||
const newValid = source.choice(0.5) ? type.generateValid(source) : oldValid
|
||||
const didChange = !isEqual(oldValid, newValid)
|
||||
const invalid = type.generateInvalid(source)
|
||||
|
||||
expect(type.validator.validate(oldValid)).toBe(oldValid)
|
||||
expect(type.validator.validate(newValid)).toBe(newValid)
|
||||
expect(() => {
|
||||
type.validator.validate(invalid)
|
||||
}).toThrow()
|
||||
|
||||
expect(() => type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).not.toThrow()
|
||||
expect(() => type.validator.validateUsingKnownGoodVersion(oldValid, invalid)).toThrow()
|
||||
|
||||
if (didChange) {
|
||||
expect(type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).toBe(newValid)
|
||||
} else {
|
||||
expect(type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).toBe(oldValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const NUM_TESTS = 1000
|
||||
const source = new RandomSource(Math.random())
|
||||
|
||||
// 54480484
|
||||
const onlySeed: null | number = null
|
||||
|
||||
if (onlySeed) {
|
||||
runTest(onlySeed)
|
||||
} else {
|
||||
for (let i = 0; i < NUM_TESTS; i++) {
|
||||
const seed = source.nextInt(100000000)
|
||||
runTest(seed)
|
||||
}
|
||||
}
|
||||
60
packages/validate/src/test/validator.test.ts
Normal file
60
packages/validate/src/test/validator.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as T from '../lib/validation'
|
||||
import { ValidationError } from '../lib/validation'
|
||||
|
||||
describe('§3 The validator core', () => {
|
||||
it('[V1] validate returns exactly the value it was passed', () => {
|
||||
const validator = T.object({
|
||||
name: T.string,
|
||||
items: T.arrayOf(T.string.nullable()),
|
||||
})
|
||||
|
||||
const value = {
|
||||
name: 'toad',
|
||||
items: ['toad', 'berd', null, 'bot'],
|
||||
}
|
||||
|
||||
expect(validator.validate(value)).toBe(value)
|
||||
})
|
||||
|
||||
it('[V1] validate does not mutate or freeze the value', () => {
|
||||
const value = { name: 'toad', items: [1, 2] }
|
||||
T.object({ name: T.string, items: T.arrayOf(T.number) }).validate(value)
|
||||
|
||||
expect(value).toEqual({ name: 'toad', items: [1, 2] })
|
||||
expect(Object.isFrozen(value)).toBe(false)
|
||||
expect(Object.isFrozen(value.items)).toBe(false)
|
||||
})
|
||||
|
||||
it('[V2] a non-transforming validator returning a different value throws in dev', () => {
|
||||
const validator = new T.Validator((value) => ({ ...(value as object) }))
|
||||
|
||||
expect(() => validator.validate({ a: 1 })).toThrow(
|
||||
'Validator functions must return the same value they were passed'
|
||||
)
|
||||
})
|
||||
|
||||
it('[V3] isValid returns a boolean and never throws', () => {
|
||||
expect(T.number.isValid(1)).toBe(true)
|
||||
expect(T.number.isValid('one')).toBe(false)
|
||||
expect(T.object({ a: T.string }).isValid(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('[V4] validateUsingKnownGoodVersion returns the known-good value without validating when Object.is-equal', () => {
|
||||
let calls = 0
|
||||
const counting = new T.Validator<number>((value) => {
|
||||
calls++
|
||||
if (typeof value !== 'number') throw new ValidationError('Expected number')
|
||||
return value
|
||||
})
|
||||
|
||||
expect(counting.validateUsingKnownGoodVersion(42, 42)).toBe(42)
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('[V5] validateUsingKnownGoodVersion falls back to a full validate without a known-good implementation', () => {
|
||||
expect(T.string.validateUsingKnownGoodVersion('a', 'b')).toBe('b')
|
||||
expect(() => T.string.validateUsingKnownGoodVersion('a', 5)).toThrow(
|
||||
'Expected string, got a number'
|
||||
)
|
||||
})
|
||||
})
|
||||
7
packages/validate/tsconfig.json
Normal file
7
packages/validate/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../internal/config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src" },
|
||||
"references": [{ "path": "../utils" }]
|
||||
}
|
||||
6
packages/validate/vitest.config.ts
Normal file
6
packages/validate/vitest.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vitest" />
|
||||
import { mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../internal/config/vitest/node-preset'
|
||||
|
||||
// Validate package uses the standard node preset with no overrides
|
||||
export default mergeConfig(baseConfig, {})
|
||||
Reference in New Issue
Block a user