| title | How to create a Version Flow |
|---|
This tutorial demonstrates how to use the VersionFlow class to validate and migrate schemas across different versions of your data structures.
Before starting, install the required dependencies:
npm install @piadina/scheme-up zodWe'll use Zod for runtime validation in our advanced examples, as it provides:
- Type-safe schema validation with automatic TypeScript inference
- Composable validation logic for complex nested structures
- Rich error messages with detailed path information
- Declarative syntax that's more maintainable than manual validation
Let's start with a practical example where you have Custom User Preferences that follow a V1 schema.
Purpose: Establish the structure for your versioned data schema with strict TypeScript types.
// types/user-preferences.ts
export interface UserPreferencesV1Data {
// Simple string for theme preference
theme: string;
// User's preferred language code
language: string;
// Numeric font size in pixels
fontSize: number;
}
export interface UserPreferencesV1 {
// Explicit version field for schema identification
version: '1.0.0';
// Actual preference data wrapped in a data field
data: UserPreferencesV1Data;
}Key Implementation Notes:
- The
versionfield uses a literal type ('1.0.0') to ensure exact version matching - Separating
Dataand main interfaces allows for cleaner type composition later - The versioned wrapper pattern (
{ version, data }) is essential for the migration system
Purpose: Implement runtime type validation to ensure data integrity before processing.
It is a custom Assert Type function. Later in the tutorial we will see how to integrate 3rd party libraries like zod to handle the schema validation.
// validators/user-preferences.ts
import type { UserPreferencesV1 } from '../types/user-preferences';
export function assertUserPreferencesV1(input: unknown): UserPreferencesV1 {
// First-level validation: ensure we have an object to work with
if (!input || typeof input !== 'object') {
throw new Error('Input must be an object');
}
const obj = input as Record<string, unknown>;
// Version validation: critical for migration chain selection
if (obj.version !== '1.0.0') {
throw new Error('Expected version 1.0.0');
}
// Data container validation
if (!obj.data || typeof obj.data !== 'object') {
throw new Error('Data must be an object');
}
const data = obj.data as Record<string, unknown>;
// Field-by-field validation with specific error messages
if (typeof data.theme !== 'string') {
throw new Error('Data.theme must be a string');
}
if (typeof data.language !== 'string') {
throw new Error('Data.language must be a string');
}
if (typeof data.fontSize !== 'number') {
throw new Error('Data.fontSize must be a number');
}
// TypeScript assertion: tells the compiler this function validates the type
return input as UserPreferencesV1;
}Key Implementation Notes:
- Acts as a type guard - validates unknown input and narrows TypeScript types
- Progressive validation from container to specific fields prevents runtime errors
- Explicit error messages help with debugging validation failures
- Version checking is crucial for the migration system to select the correct chain
Purpose: Create the validation pipeline and integrate it with data fetching logic.
// Create version flow for V1
const preferencesFlow = new VersionFlow<UserPreferencesV1>()
.add<UserPreferencesV1>((builder) => {
builder
// Schema version this node handles
.version('1.0.0')
// Semantic version range (compatible with 1.x.x)
.semverRange('^1.0.0')
// Validation function to run
.assert(assertUserPreferencesV1);
});
export async function fetchUserPreferences() {
try {
// Standard API call to fetch user data
const response = await fetch('/api/user-preferences');
const rawData = await response.json();
// Execute validation through the version flow
const schemaResult = preferencesFlow.execute(rawData);
const [ isSuccess, result ] = schemaResult;
if (!isSuccess) {
console.error('Validation failed:', result.message);
// Return the error tuple
return schemaResult;
}
// Extract validated data from the wrapper
return [ true, result.data ] as const;
} catch (error) {
// Handle network/parsing errors separately from validation errors
return [
false,
new Error(`Fetch failed: ${ error instanceof Error ? error.message : 'Unknown error' }`),
] as const;
}
}
// Usage example
async function loadUserPreferences() {
const [ isSuccess, result ] = await fetchUserPreferences();
if (!isSuccess) {
console.error('Failed to load preferences:', result.message);
// Handle error case
return;
}
// TypeScript now knows `result` is UserPreferencesV1Data
console.log('User theme:', result.theme);
console.log('User language:', result.language);
console.log('Font size:', result.fontSize);
}Key Implementation Notes:
- Builder pattern allows for fluent API configuration
- Semver ranges provide flexibility in version compatibility (^1.0.0 accepts 1.0.1, 1.1.0, etc.)
- Tuple return pattern
[boolean, T | Error]enables clean error handling without exceptions - Type safety is maintained throughout - TypeScript knows the exact type after successful validation
Now let's upgrade your User Preferences to V2 and add a migration strategy.
Purpose: Evolve your data structure while maintaining backward compatibility through versioning.
// types/user-preferences.ts (updated)
export interface UserPreferencesV1Data {
theme: string;
language: string;
fontSize: number;
}
export interface UserPreferencesV2Data {
// Reorganized into logical groups for better UX
appearance: {
// More specific theme options
theme: 'light' | 'dark' | 'auto';
// Human-readable size names
fontSize: 'small' | 'medium' | 'large';
};
locale: {
// Moved to locale group
language: string;
// Added region support
region: string;
};
accessibility: {
// New accessibility feature
highContrast: boolean;
};
}
export interface UserPreferencesV1 {
version: '1.0.0';
data: UserPreferencesV1Data;
}
export interface UserPreferencesV2 {
// Bumped major version
version: '2.0.0';
data: UserPreferencesV2Data;
}Key Implementation Notes:
- Breaking changes warrant a major version bump (1.0.0 β 2.0.0)
- Grouped properties improve organization and future extensibility
- Enum-like unions (
'light' | 'dark' | 'auto') provide better type safety than free strings - Additive changes like new fields require migration logic to handle legacy data
Purpose: Implement validation logic for the new V2 schema using Zod for more concise and powerful validation.
We use zod library to create an validate the schema.
// validators/user-preferences.ts (updated)
import { z } from 'zod';
import type { UserPreferencesV1, UserPreferencesV2 } from '../types/user-preferences';
// ... existing V1 validator ...
// Define Zod schema for V2 validation
const UserPreferencesV2Schema = z.object({
// Exact version match
version: z.literal('2.0.0'),
data: z.object({
appearance: z.object({
// Enum validation with clear options
theme: z.enum(['light', 'dark', 'auto']),
fontSize: z.enum(['small', 'medium', 'large']),
}),
locale: z.object({
// Non-empty string validation
language: z.string().min(1),
region: z.string().min(1),
}),
accessibility: z.object({
// Strict boolean validation
highContrast: z.boolean(),
}),
}),
});
export function assertUserPreferencesV2(input: unknown): asserts input is UserPreferencesV2 {
try {
// throw if invalid
UserPreferencesV2Schema.parse(input);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessages = error.errors.map(err =>
`${err.path.join('.')}: ${err.message}`
).join(', ');
throw new Error(`V2 schema validation failed: ${errorMessages}`);
}
throw error;
}
}Key Implementation Notes:
- Declarative schema - Zod schema reads like documentation and is much more concise
- Built-in type inference - Zod automatically infers TypeScript types from the schema
- Rich validation -
z.enum(),z.literal(), andz.string().min(1)provide precise validation - Better error messages - Zod provides detailed path-based error reporting
- Composable validation - Nested
z.object()calls naturally handle complex structures - Two validation approaches -
parse()throws errors,safeParse()returns result objects - Type safety - The validated result is automatically typed as
UserPreferencesV2
Purpose: Create a migration chain that automatically upgrades V1 data to V2 format.
// Updated version flow with V1 to V2 migration
const preferencesFlow = new VersionFlow<UserPreferencesV2>()
.add<UserPreferencesV1, UserPreferencesV2>((builder) => {
builder
// Source version
.version('1.0.0')
// Accepts any 1.x.x version
.semverRange('^1.0.0')
// Validate input as V1
.assert(assertUserPreferencesV1)
// Transform V1 β V2
.migrate((v1) => {
// Migration logic with explicit mappings
const fontSizeMap: Record<number, 'small' | 'medium' | 'large'> = {
12: 'small',
14: 'medium',
16: 'large',
};
const themeMap: Record<string, 'light' | 'dark' | 'auto'> = {
light: 'light',
dark: 'dark',
// Map old 'system' to new 'auto'
system: 'auto',
};
return {
// Target version
version: '2.0.0',
data: {
appearance: {
// Fallback to 'auto'
theme: themeMap[v1.data.theme] || 'auto',
// Fallback to 'medium'
fontSize: fontSizeMap[v1.data.fontSize] || 'medium',
},
locale: {
language: v1.data.language,
// Smart region defaulting
region: v1.data.language === 'en' ? 'US' : 'GB',
},
accessibility: {
// Default value for new field
highContrast: false,
},
},
};
});
})
// Handle native V2 data
.add<UserPreferencesV2>((builder) => {
builder
// Accepts any 2.x.x version
.version('2.0.0')
.semverRange('^2.0.0')
// Just validate, no migration needed
.assert(assertUserPreferencesV2);
});
// Usage example
async function loadUserPreferences() {
const [ isSuccess, result ] = await fetchUserPreferences();
if (!isSuccess) {
console.error('Failed to load preferences:', result.message);
return;
}
// Access V2 structure regardless of input version
console.log('Theme:', result.appearance.theme);
console.log('Font size:', result.appearance.fontSize);
console.log('Language:', result.locale.language);
console.log('Region:', result.locale.region);
console.log('High contrast:', result.accessibility.highContrast);
}Key Implementation Notes:
- Migration chain processes data sequentially: V1 input β V1 validation β V1βV2 migration β V2 validation
- Explicit mapping objects make transformation logic clear and maintainable
- Fallback values handle edge cases where direct mapping isn't possible
- Smart defaults for new fields (like region based on language) improve user experience
- Final output is always the target type (V2), regardless of input version
Purpose: Add basic error handling to log validation failures without breaking the application flow.
// services/user-preferences.ts (with error handling)
import { VersionFlow } from '@piadina/scheme-up';
import type { UserPreferencesV2Data } from '../types/user-preferences';
const preferencesFlow = new VersionFlow<UserPreferencesV2>()
.catch((error) => {
// Simple error logging without recovery
console.error('Schema validation error:', error);
// Don't return a value - let the error propagate
// This approach is useful for debugging and monitoring
})
.add<UserPreferencesV1, UserPreferencesV2>((builder) => {
builder
.version('1.0.0')
.semverRange('^1.0.0')
.assert(assertUserPreferencesV1)
.migrate((v1) => ({
version: '2.0.0',
data: {
appearance: {
// Simple fallback values
theme: 'auto',
fontSize: 'medium',
},
locale: {
language: v1.data.language,
region: 'US',
},
accessibility: {
highContrast: false,
},
},
}));
})
.add<UserPreferencesV2>((builder) => {
builder
.version('2.0.0')
.semverRange('^2.0.0')
.assert(assertUserPreferencesV2);
});Key Implementation Notes:
- Logging-only approach - catches errors for monitoring but doesn't recover
- Error propagation - the original error continues up the call stack
- Useful for debugging - provides visibility into validation failures
- No fallback data - application must handle the error case explicitly
Purpose: Implement graceful degradation by providing fallback data when validation fails.
// services/user-preferences.ts (with fallback recovery)
const preferencesFlow = new VersionFlow<UserPreferencesV2>()
.catch((error) => {
console.error('Schema validation failed, using fallback preferences:', error);
// Return complete fallback user preferences
return {
version: '2.0.0',
data: {
appearance: {
// Safe default theme
theme: 'light',
// Safe default size
fontSize: 'medium',
},
locale: {
// Default to English
language: 'en',
// Default to US region
region: 'US',
},
accessibility: {
// Conservative accessibility default
highContrast: false,
},
},
} as UserPreferencesV2;
})
.add<UserPreferencesV1, UserPreferencesV2>((builder) => {
builder
.version('1.0.0')
.semverRange('^1.0.0')
.assert(assertUserPreferencesV1)
.migrate((v1) => ({
version: '2.0.0',
data: {
appearance: {
// More sophisticated migration logic
theme: v1.data.theme === 'dark' ? 'dark' : 'light',
fontSize: v1.data.fontSize > 14 ? 'large' : 'medium',
},
locale: {
language: v1.data.language,
// Default **region**
region: 'US',
},
accessibility: {
highContrast: false,
},
},
}));
})
.add<UserPreferencesV2>((builder) => {
builder
.version('2.0.0')
.semverRange('^2.0.0')
.assert(assertUserPreferencesV2);
});Key Implementation Notes:
- Graceful degradation - application continues with sensible defaults when data is invalid
- Complete fallback object - provides all required fields for the target schema
- Conservative defaults - chooses safe, widely-compatible values
- Error recovery - transforms failures into successful results with fallback data
- User experience - prevents application crashes due to data validation issues
This tutorial demonstrates how to:
-
Start simple with basic validation and error handling
- Define strict TypeScript interfaces with explicit version fields
- Create comprehensive validation functions that act as type guards
- Set up basic version flows for single-version scenarios
-
Evolve your schema while maintaining backward compatibility through migrations
- Design new schema versions with logical grouping and improved types
- Implement migration functions that transform old data to new formats
- Use mapping objects and fallback values for robust data transformation
-
Handle errors gracefully with both simple logging and sophisticated fallback strategies
- Log validation errors for debugging and monitoring
- Provide fallback data to ensure application resilience
- Choose between error propagation and graceful degradation based on use case
-
Build resilient applications that work even when data validation fails
- Implement tuple-based error handling for clean, functional error management
- Maintain type safety throughout the validation and migration process
- Create flexible semver-based version ranges for forward compatibility
Core Benefits of the VersionFlow Pattern:
- Type Safety: TypeScript ensures correctness at compile time
- Runtime Validation: Protects against invalid data from external sources
- Schema Evolution: Enables gradual migration of data structures
- Error Resilience: Provides multiple strategies for handling validation failures
- Developer Experience: Fluent API makes complex validation chains readable
The VersionFlow class provides a foundation for managing schema evolution in your applications while ensuring type safety and graceful error handling.