18 KiB
Web App Compile-Time Correctness Plan
Problem Statement
The DataBuild web application currently has a type safety blindspot where backend protobuf changes can cause runtime failures in the frontend without any compile-time warnings. While we achieved end-to-end type generation (Proto → Rust → OpenAPI → TypeScript), inconsistent data transformation patterns and loose TypeScript configuration allow type mismatches to slip through.
Specific observed failures:
status.toLowerCase()crashes when status objects are passed instead of stringsstatus?.statusaccesses non-existent properties on protobuf response objects- Partitions page fails silently due to unhandled nullability
- Inconsistent data shapes flowing through components
Root Cause Analysis
- Mixed Data Contracts: Some components expect
{ status: string }while APIs return{ status_code: number, status_name: string } - Inconsistent Transformations: Data shape changes happen ad-hoc throughout the component tree
- Protobuf Nullability: Generated types are honest about optional fields, but TypeScript config allows unsafe access
- Service Boundary Leakage: Backend implementation details leak into frontend components
Solution: Three-Pronged Approach
Option 2: Consistent Data Transformation (Primary)
- Define canonical dashboard types separate from generated API types
- Transform data at service boundaries, never in components
- Single source of truth for data shapes within the frontend
Option 4: Generated Type Enforcement (Supporting)
- Use generated protobuf types in service layer for accurate contracts
- Leverage protobuf's honest nullability information
- Maintain type safety chain from backend to service boundary
Option 3: Stricter TypeScript Configuration (Foundation)
- Enable strict null checks to catch undefined access patterns
- Prevent implicit any types that mask runtime errors
- Force explicit handling of protobuf's optional fields
Implementation Plan
Phase 1: TypeScript Configuration Hardening
Goal: Enable strict type checking to surface existing issues
Tasks:
-
Update
tsconfig.jsonwith strict configuration:{ "compilerOptions": { "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noImplicitReturns": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true } } -
Run TypeScript compilation to identify all type errors
-
Create tracking issue for each compilation error
Success Criteria: TypeScript build passes with strict configuration enabled
Estimated Time: 1-2 days
Phase 1.5: Verification of Strict Configuration
Goal: Prove strict TypeScript catches the specific issues we identified
Tasks:
-
Create test cases that reproduce original failures:
// Test file: dashboard/verification-tests.ts const mockResponse = { status_code: 1, status_name: "COMPLETED" }; // These should now cause TypeScript compilation errors: const test1 = mockResponse.status?.toLowerCase(); // undefined property access const test2 = mockResponse.status?.status; // nested undefined access -
Run TypeScript compilation and verify these cause errors:
- Document which strict rules catch which specific issues
- Confirm
strictNullChecksprevents undefined property access - Verify
noImplicitAnysurfaces type gaps
-
Test protobuf nullable field handling:
interface TestPartitionSummary { last_updated?: number; // optional field from protobuf } // This should require explicit null checking: const timestamp = partition.last_updated.toString(); // Should error
Success Criteria:
- All identified runtime failures now cause compile-time errors
- Clear mapping between strict TypeScript rules and caught issues
- Zero false positives in existing working code
Estimated Time: 0.5 days
Phase 2: Define Dashboard Data Contracts
Goal: Create canonical frontend types independent of backend schema
Tasks:
-
Define dashboard types in
dashboard/types.ts:// Dashboard-optimized types interface DashboardBuild { build_request_id: string; status: string; // Always human-readable name requested_partitions: string[]; // Always string values total_jobs: number; completed_jobs: number; failed_jobs: number; cancelled_jobs: number; requested_at: number; started_at: number | null; completed_at: number | null; duration_ms: number | null; cancelled: boolean; } interface DashboardPartition { partition_ref: string; // Always string value status: string; // Always human-readable name last_updated: number | null; build_requests: string[]; } interface DashboardJob { job_label: string; total_runs: number; successful_runs: number; failed_runs: number; cancelled_runs: number; last_run_timestamp: number; last_run_status: string; // Always human-readable name average_partitions_per_run: number; recent_builds: string[]; } -
Update component attribute interfaces to use dashboard types
-
Document the rationale for each transformation decision
Success Criteria: All dashboard types are self-contained and UI-optimized
Estimated Time: 2-3 days
Phase 3: Service Layer Transformation
Goal: Create consistent transformation boundaries between API and dashboard
Tasks:
-
Implement transformation functions in
services.ts:// Transform API responses to dashboard types function transformBuildDetail(apiResponse: BuildDetailResponse): DashboardBuild { return { build_request_id: apiResponse.build_request_id, status: apiResponse.status_name, requested_partitions: apiResponse.requested_partitions.map(p => p.str), total_jobs: apiResponse.total_jobs, completed_jobs: apiResponse.completed_jobs, failed_jobs: apiResponse.failed_jobs, cancelled_jobs: apiResponse.cancelled_jobs, requested_at: apiResponse.requested_at, started_at: apiResponse.started_at ?? null, completed_at: apiResponse.completed_at ?? null, duration_ms: apiResponse.duration_ms ?? null, cancelled: apiResponse.cancelled, }; } function transformPartitionSummary(apiResponse: PartitionSummary): DashboardPartition { return { partition_ref: apiResponse.partition_ref.str, status: apiResponse.status_name, last_updated: apiResponse.last_updated ?? null, build_requests: apiResponse.build_requests, }; } -
Update all service methods to use transformation functions
-
Add type guards for runtime validation:
function isValidBuildResponse(data: unknown): data is BuildDetailResponse { return typeof data === 'object' && data !== null && 'build_request_id' in data && 'status_name' in data; } -
Handle API errors with proper typing
Success Criteria: All API data flows through consistent transformation layer
Estimated Time: 3-4 days
Phase 3.5: Transformation Validation
Goal: Prove transformation functions prevent observed failures and handle edge cases
Tasks:
-
Create comprehensive unit tests for transformation functions:
// Test file: dashboard/transformation-tests.ts describe('transformBuildDetail', () => { it('handles status objects correctly', () => { const apiResponse = { status_code: 1, status_name: 'COMPLETED' }; const result = transformBuildDetail(apiResponse); expect(typeof result.status).toBe('string'); expect(result.status).toBe('COMPLETED'); }); it('handles null optional fields', () => { const apiResponse = { started_at: null, completed_at: undefined }; const result = transformBuildDetail(apiResponse); expect(result.started_at).toBe(null); expect(result.completed_at).toBe(null); }); }); -
Test edge cases and malformed responses:
- Missing required fields
- Null values where not expected
- Wrong data types in API responses
- Verify type guards catch invalid responses
-
Validate PartitionRef transformations:
it('converts PartitionRef objects to strings', () => { const apiResponse = { partition_ref: { str: 'test-partition' } }; const result = transformPartitionSummary(apiResponse); expect(typeof result.partition_ref).toBe('string'); expect(result.partition_ref).toBe('test-partition'); }); -
Test transformation against real protobuf response shapes:
- Use actual OpenAPI generated types in tests
- Verify transformations work with current API schema
- Document transformation rationale for each field
Success Criteria:
- All transformation functions have >90% test coverage
- Edge cases and null handling verified
- Real API response shapes handled correctly
- Type guards prevent invalid data from reaching components
Estimated Time: 1 day
Phase 4: Component Migration
Goal: Update all components to use dashboard types exclusively
Tasks:
-
Update component implementations to use dashboard types:
- Remove direct
.status_code/.status_nameaccess - Use transformed string status values
- Handle null values explicitly where needed
- Remove direct
-
Fix specific identified issues:
- Line 472:
status?.status→ usestatusdirectly - Badge components: Ensure they receive strings
- Partition list: Use consistent partition type
- Line 472:
-
Update component attribute interfaces to match dashboard types
-
Add runtime assertions where needed:
if (!status) { console.warn('Missing status in component'); return m('span', 'Unknown Status'); }
Success Criteria: All components compile and work with dashboard types
Estimated Time: 2-3 days
Phase 4.5: Continuous Component Verification
Goal: Verify components work correctly with dashboard types throughout migration
Tasks:
-
After each component migration, run verification tests:
// Component-specific tests describe('BuildDetailComponent', () => { it('renders status as string correctly', () => { const dashboardBuild: DashboardBuild = { status: 'COMPLETED', // Transformed string, not object // ... other fields }; const component = m(BuildDetailComponent, { build: dashboardBuild }); // Verify no runtime errors with .toLowerCase() }); }); -
Test component attribute interfaces match usage:
- Verify TypeScript compilation passes for each component
- Check that vnode.attrs typing prevents invalid property access
- Test null handling in component rendering
-
Integration tests with real transformed data:
- Use actual service layer transformation outputs
- Verify components render correctly with dashboard types
- Test error states and missing data scenarios
Success Criteria:
- Each migrated component passes TypeScript compilation
- No runtime errors when using transformed dashboard types
- Components gracefully handle null/undefined dashboard fields
Estimated Time: 0.5 days (distributed across Phase 4)
Phase 5: Schema Change Simulation & Integration Testing
Goal: Verify end-to-end compile-time correctness with simulated backend changes
Tasks:
-
Automated Schema Change Testing:
# Create test script: scripts/test-schema-changes.sh # Test 1: Add new required field to protobuf # - Modify databuild.proto temporarily # - Regenerate Rust types and OpenAPI schema # - Verify TypeScript compilation fails predictably # - Document exact error messages # Test 2: Remove existing field # - Remove field from protobuf definition # - Verify transformation functions catch missing fields # - Confirm components fail compilation when accessing removed field # Test 3: Change field type (string → object) # - Modify status field structure in protobuf # - Verify transformation layer prevents type mismatches # - Confirm this catches issues like original status.toLowerCase() failure -
Full Build Cycle Verification:
- Proto change →
bazel build //databuild:openapi_spec_generator - OpenAPI regeneration →
bazel build //databuild/client:typescript_client - TypeScript compilation →
bazel build //databuild/dashboard:* - Document each failure point and error messages
- Proto change →
-
End-to-End Type Safety Validation:
// Create comprehensive integration tests describe('End-to-End Type Safety', () => { it('prevents runtime failures from schema changes', async () => { // Test actual API calls with transformed responses const service = DashboardService.getInstance(); const activity = await service.getRecentActivity(); // Verify transformed types prevent original failures activity.recentBuilds.forEach(build => { expect(typeof build.status).toBe('string'); expect(() => build.status.toLowerCase()).not.toThrow(); }); }); }); -
Regression Testing for Original Failures:
- Test status.toLowerCase() with transformed data
- Test status?.status access patterns
- Test partition.str access with transformed partition refs
- Verify null handling in timestamp fields
-
Real Data Flow Testing:
- New build creation → status updates → completion
- Partition status changes using dashboard types
- Job execution monitoring with transformed data
- Error states and edge cases
Success Criteria:
- Schema changes cause predictable TypeScript compilation failures
- Transformation layer prevents all identified runtime failures
- Full build cycle catches type mismatches at each stage
- Zero runtime type errors with dashboard types
- Original failure scenarios now impossible with strict types
Estimated Time: 2-3 days
Phase 6: Documentation & Monitoring
Goal: Establish practices to maintain type safety over time
Tasks:
-
Document transformation patterns:
- When to create new dashboard types
- How to handle protobuf schema changes
- Service layer responsibilities
-
Add runtime monitoring:
- Log transformation failures
- Track API response shape mismatches
- Monitor for unexpected null values
-
Create development guidelines:
- Never use generated types directly in components
- Always transform at service boundaries
- Handle nullability explicitly
-
Set up CI checks:
- Strict TypeScript compilation in build pipeline
- Automated schema change detection tests
- Integration test suite for type safety validation
- Pre-commit hooks for TypeScript compilation
-
Create Ongoing Verification Tools:
# CI script: scripts/verify-type-safety.sh # - Run schema change simulation tests # - Verify transformation tests pass # - Check strict TypeScript compilation # - Validate component integration tests
Success Criteria:
- Team has clear practices for maintaining type safety
- CI pipeline catches type safety regressions automatically
- Schema change testing is automated and repeatable
- Documentation provides concrete examples and rationale
Estimated Time: 2 days
Risk Mitigation
High-Impact Risks
-
Breaking Change Volume: Strict TypeScript may reveal many existing issues
- Mitigation: Implement incrementally, fix issues in phases
- Rollback: Keep loose config as backup during transition
-
Performance Impact: Additional transformation layer overhead
- Mitigation: Profile transformation functions, optimize hot paths
- Monitoring: Track bundle size and runtime performance
-
Developer Learning Curve: Team needs to adapt to strict null checks
- Mitigation: Provide training on handling optional types
- Support: Create examples and best practices documentation
Medium-Impact Risks
-
API Response Changes: Backend might return unexpected data shapes
- Mitigation: Add runtime validation in service layer
- Detection: Monitor for transformation failures
-
Third-party Type Conflicts: Generated types might conflict with other libraries
- Mitigation: Use type aliases and careful imports
- Testing: Verify integration with existing dependencies
Success Metrics
Compile-Time Safety
- Zero
anytypes in dashboard code - All protobuf optional fields handled explicitly
- TypeScript strict mode enabled and passing
- Component attribute interfaces match usage
Runtime Reliability
- Zero "undefined is not a function" errors
- Zero "cannot read property of undefined" errors
- All API error states handled gracefully
- Consistent data shapes across all components
Development Experience
- Backend schema changes cause predictable frontend compilation results
- Clear error messages when types don't match
- Consistent patterns for handling new data types
- Fast iteration cycle maintained
Future Considerations
Schema Evolution Strategy
- Plan for handling breaking vs non-breaking backend changes
- Consider versioning approach for dashboard types
- Establish deprecation process for old data shapes
Tooling Enhancements
- Consider code generation for transformation functions
- Explore runtime schema validation libraries
- Investigate GraphQL for stronger API contracts
Performance Optimization
- Profile transformation layer performance
- Consider caching strategies for transformed data
- Optimize bundle size impact of strict typing
Implementation Notes
This plan prioritizes compile-time correctness while maintaining development velocity. The phased approach allows for incremental progress and risk mitigation, while the three-pronged strategy (Options 2+3+4) provides comprehensive type safety from protobuf definitions through to component rendering.
The key insight is that true compile-time correctness requires both accurate type definitions AND consistent data transformation patterns enforced by strict TypeScript configuration.