- Add new reward-order-confirmation feature library with components and store - Implement checkout completion orchestrator service for order finalization - Migrate checkout/oms/crm models to Zod schemas for better type safety - Add order creation facade and display order schemas - Update shopping cart facade with order completion flow - Add comprehensive tests for shopping cart facade - Update routing to include order confirmation page
@isa/core/config
A lightweight, type-safe configuration management system for Angular applications with runtime validation and nested object access.
Overview
The Core Config library provides a simple yet powerful configuration service for Angular applications. It enables type-safe access to application configuration data with Zod schema validation, supports nested object navigation with dot notation, and integrates seamlessly with Angular's dependency injection system. The library is designed for runtime configuration that needs to be injected at application startup from external sources like environment files or remote configuration servers.
Table of Contents
- Features
- Quick Start
- Core Concepts
- API Reference
- Usage Examples
- Configuration Patterns
- Testing
- Architecture Notes
Features
- Type-safe configuration - Zod schema validation for runtime safety
- Nested object access - Dot notation for deep property paths
- Dependency injection - Angular injection token integration
- Flexible data structure - Support for primitives, arrays, and objects
- Array coercion - Automatic handling of string or array paths
- Runtime validation - Validate configuration at runtime with schemas
- Lightweight - Minimal dependencies, simple implementation
- JSON-compatible - Works with standard JSON configuration data
Quick Start
1. Provide Configuration Data
import { ApplicationConfig } from '@angular/core';
import { CONFIG_DATA } from '@isa/core/config';
const appConfig = {
api: {
baseUrl: 'https://api.example.com',
timeout: 30000,
retries: 3
},
features: {
enableBeta: false,
maxUploadSize: 10485760
},
branding: {
appName: 'My Application',
theme: 'light'
}
};
export const config: ApplicationConfig = {
providers: [
{
provide: CONFIG_DATA,
useValue: appConfig
}
]
};
2. Inject and Use Config Service
import { Component, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';
@Component({
selector: 'app-root',
template: '...'
})
export class AppComponent {
#config = inject(Config);
// Access with validation
apiConfig = this.#config.get(
'api',
z.object({
baseUrl: z.string().url(),
timeout: z.number().positive(),
retries: z.number().int().min(0)
})
);
// Access nested property
appName = this.#config.get('branding.appName', z.string());
ngOnInit() {
console.log(`API URL: ${this.apiConfig.baseUrl}`);
console.log(`App name: ${this.appName}`);
}
}
3. Access Without Validation (Deprecated)
// Not recommended - use Zod schema instead
const rawValue = this.#config.get('api.baseUrl'); // any type
Core Concepts
Configuration Data Structure
The configuration data must be JSON-compatible:
type JsonPrimitive = string | number | boolean | null | undefined;
type JsonValue =
| Array<JsonPrimitive>
| Record<string, JsonPrimitive>
| JsonPrimitive;
Supported Types:
- Primitives: string, number, boolean, null, undefined
- Objects: Records with primitive values
- Arrays: Arrays of primitives
Example Configuration:
const config = {
// Primitives
appName: 'ISA Frontend',
port: 8080,
debugMode: true,
// Nested objects
database: {
host: 'localhost',
port: 5432,
name: 'mydb'
},
// Arrays
allowedOrigins: ['http://localhost:4200', 'https://example.com'],
// Nested with arrays
features: {
enabled: ['auth', 'reporting', 'analytics']
}
};
Dot Notation Access
Access nested properties using dot notation:
// Direct property
this.#config.get('appName', z.string());
// Nested property
this.#config.get('database.host', z.string());
// Deep nesting
this.#config.get('features.beta.settings.maxUsers', z.number());
Path Resolution:
// String path
'database.host' → config.database.host
// Array path (equivalent)
['database', 'host'] → config.database.host
// Mixed array path
['features', 'beta', 'settings'] → config.features.beta.settings
Zod Schema Validation
All configuration access should use Zod schemas for type safety:
import { z } from 'zod';
// Simple primitive
const port = this.#config.get('port', z.number());
// Object with validation
const dbConfig = this.#config.get(
'database',
z.object({
host: z.string().min(1),
port: z.number().int().positive(),
name: z.string()
})
);
// Array with validation
const origins = this.#config.get(
'allowedOrigins',
z.array(z.string().url())
);
// Optional values
const optionalFeature = this.#config.get(
'features.experimental',
z.string().optional()
);
// Default values
const timeout = this.#config.get(
'api.timeout',
z.number().default(30000)
);
API Reference
CONFIG_DATA
Injection token for providing configuration data.
Type: InjectionToken<JsonValue>
Usage:
import { CONFIG_DATA } from '@isa/core/config';
{
provide: CONFIG_DATA,
useValue: myConfigObject
}
Config Service
Injectable service for accessing configuration data.
get<TOut>(path: string | string[], zSchema: z.ZodSchema<TOut>): TOut
Retrieves and validates configuration value at the specified path.
Parameters:
path: string | string[]- Path to configuration value (dot notation or array)zSchema: z.ZodSchema<TOut>- Zod schema for validation and type inference
Returns: Validated value of type TOut
Throws: ZodError if validation fails
Example:
const apiUrl = this.#config.get('api.baseUrl', z.string().url());
// apiUrl is typed as string and validated as URL
const config = this.#config.get(
'database',
z.object({
host: z.string(),
port: z.number()
})
);
// config is typed as { host: string; port: number; }
get(path: string | string[]): any (Deprecated)
Retrieves configuration value without validation.
Parameters:
path: string | string[]- Path to configuration value
Returns: Unvalidated value of type any
Deprecated: Use the version with Zod schema instead
Example:
// Not recommended
const value = this.#config.get('some.path'); // Type: any
Usage Examples
Basic Configuration Access
import { Component, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';
@Component({
selector: 'app-api-client',
template: '...'
})
export class ApiClientComponent {
#config = inject(Config);
// Get string value
apiBaseUrl = this.#config.get('api.baseUrl', z.string());
// Get number value
timeout = this.#config.get('api.timeout', z.number());
// Get boolean value
debugMode = this.#config.get('debug', z.boolean());
ngOnInit() {
console.log(`Connecting to ${this.apiBaseUrl}`);
console.log(`Timeout: ${this.timeout}ms`);
console.log(`Debug: ${this.debugMode}`);
}
}
Complex Object Configuration
import { Component, inject, OnInit } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';
// Define schema
const DatabaseConfigSchema = z.object({
host: z.string().min(1),
port: z.number().int().positive(),
database: z.string().min(1),
username: z.string(),
password: z.string(),
ssl: z.boolean().default(false),
poolSize: z.number().int().positive().default(10)
});
type DatabaseConfig = z.infer<typeof DatabaseConfigSchema>;
@Component({
selector: 'app-database',
template: '...'
})
export class DatabaseComponent implements OnInit {
#config = inject(Config);
dbConfig!: DatabaseConfig;
ngOnInit() {
this.dbConfig = this.#config.get('database', DatabaseConfigSchema);
console.log(`Connecting to ${this.dbConfig.host}:${this.dbConfig.port}`);
console.log(`Database: ${this.dbConfig.database}`);
console.log(`SSL: ${this.dbConfig.ssl}`);
}
}
Array Configuration
import { Component, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';
@Component({
selector: 'app-cors-config',
template: '...'
})
export class CorsConfigComponent {
#config = inject(Config);
// Array of strings
allowedOrigins = this.#config.get(
'cors.allowedOrigins',
z.array(z.string().url())
);
// Array of numbers
allowedPorts = this.#config.get(
'security.allowedPorts',
z.array(z.number().int().positive())
);
isOriginAllowed(origin: string): boolean {
return this.allowedOrigins.includes(origin);
}
}
Optional and Default Values
import { Component, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';
@Component({
selector: 'app-features',
template: '...'
})
export class FeaturesComponent {
#config = inject(Config);
// Optional value (may be undefined)
betaFeature = this.#config.get(
'features.beta.enabled',
z.boolean().optional()
);
// Value with default
maxUploadSize = this.#config.get(
'upload.maxSize',
z.number().default(10485760) // 10MB default
);
// Optional object
experimentalConfig = this.#config.get(
'experimental',
z.object({
enabled: z.boolean(),
features: z.array(z.string())
}).optional()
);
isBetaEnabled(): boolean {
return this.betaFeature ?? false;
}
}
Environment-Specific Configuration
// environment.ts
export const environment = {
production: false,
api: {
baseUrl: 'http://localhost:3000',
timeout: 30000
},
features: {
analytics: false,
debugPanel: true
}
};
// environment.prod.ts
export const environment = {
production: true,
api: {
baseUrl: 'https://api.production.com',
timeout: 10000
},
features: {
analytics: true,
debugPanel: false
}
};
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { CONFIG_DATA } from '@isa/core/config';
import { environment } from './environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: CONFIG_DATA,
useValue: environment
}
]
};
// api.service.ts
import { Injectable, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';
@Injectable({ providedIn: 'root' })
export class ApiService {
#config = inject(Config);
#apiConfig = this.#config.get(
'api',
z.object({
baseUrl: z.string().url(),
timeout: z.number()
})
);
get baseUrl(): string {
return this.#apiConfig.baseUrl;
}
get timeout(): number {
return this.#apiConfig.timeout;
}
}
Feature Flag Configuration
import { Injectable, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';
const FeatureFlagsSchema = z.object({
newDashboard: z.boolean().default(false),
advancedSearch: z.boolean().default(false),
exportToPdf: z.boolean().default(true),
darkMode: z.boolean().default(false),
experimentalFeatures: z.array(z.string()).default([])
});
type FeatureFlags = z.infer<typeof FeatureFlagsSchema>;
@Injectable({ providedIn: 'root' })
export class FeatureFlagService {
#config = inject(Config);
#flags: FeatureFlags = this.#config.get('features', FeatureFlagsSchema);
isEnabled(feature: keyof Omit<FeatureFlags, 'experimentalFeatures'>): boolean {
return this.#flags[feature];
}
isExperimentalEnabled(feature: string): boolean {
return this.#flags.experimentalFeatures.includes(feature);
}
}
// Usage in component
@Component({
selector: 'app-dashboard',
template: `
@if (featureFlags.isEnabled('newDashboard')) {
<app-new-dashboard />
} @else {
<app-legacy-dashboard />
}
`
})
export class DashboardComponent {
featureFlags = inject(FeatureFlagService);
}
Validation with Custom Error Handling
import { Component, inject, OnInit } from '@angular/core';
import { Config } from '@isa/core/config';
import { z, ZodError } from 'zod';
@Component({
selector: 'app-config-validator',
template: '...'
})
export class ConfigValidatorComponent implements OnInit {
#config = inject(Config);
ngOnInit() {
try {
const apiConfig = this.#config.get(
'api',
z.object({
baseUrl: z.string().url('Invalid API URL'),
timeout: z.number().positive('Timeout must be positive'),
retries: z.number().int().min(0, 'Retries cannot be negative')
})
);
console.log('Configuration valid:', apiConfig);
} catch (error) {
if (error instanceof ZodError) {
console.error('Configuration validation failed:');
error.errors.forEach(err => {
console.error(` - ${err.path.join('.')}: ${err.message}`);
});
// Fallback to defaults
this.useFallbackConfig();
}
}
}
private useFallbackConfig() {
console.warn('Using fallback configuration');
// Use hardcoded defaults
}
}
Configuration Patterns
Typed Configuration Service
Create a dedicated service with typed configuration access:
import { Injectable, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';
// Define all schemas
const ApiConfigSchema = z.object({
baseUrl: z.string().url(),
timeout: z.number().positive(),
retries: z.number().int().min(0)
});
const AuthConfigSchema = z.object({
clientId: z.string(),
redirectUri: z.string().url(),
scopes: z.array(z.string())
});
const AppConfigSchema = z.object({
name: z.string(),
version: z.string(),
buildNumber: z.string()
});
// Type exports
export type ApiConfig = z.infer<typeof ApiConfigSchema>;
export type AuthConfig = z.infer<typeof AuthConfigSchema>;
export type AppConfig = z.infer<typeof AppConfigSchema>;
@Injectable({ providedIn: 'root' })
export class AppConfigService {
#config = inject(Config);
// Lazy-loaded, cached configuration
#apiConfig?: ApiConfig;
#authConfig?: AuthConfig;
#appConfig?: AppConfig;
get api(): ApiConfig {
if (!this.#apiConfig) {
this.#apiConfig = this.#config.get('api', ApiConfigSchema);
}
return this.#apiConfig;
}
get auth(): AuthConfig {
if (!this.#authConfig) {
this.#authConfig = this.#config.get('auth', AuthConfigSchema);
}
return this.#authConfig;
}
get app(): AppConfig {
if (!this.#appConfig) {
this.#appConfig = this.#config.get('app', AppConfigSchema);
}
return this.#appConfig;
}
}
// Usage
@Component({
selector: 'app-root',
template: '...'
})
export class AppComponent {
appConfig = inject(AppConfigService);
ngOnInit() {
console.log(`API: ${this.appConfig.api.baseUrl}`);
console.log(`Auth: ${this.appConfig.auth.clientId}`);
console.log(`Version: ${this.appConfig.app.version}`);
}
}
Dynamic Configuration Loading
Load configuration from external source at startup:
import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CONFIG_DATA } from '@isa/core/config';
import { inject } from '@angular/core';
import { lastValueFrom } from 'rxjs';
function loadConfiguration() {
const http = inject(HttpClient);
return async () => {
try {
const config = await lastValueFrom(
http.get<JsonValue>('/assets/config.json')
);
return config;
} catch (error) {
console.error('Failed to load configuration', error);
// Return fallback configuration
return {
api: { baseUrl: 'http://localhost:3000' }
};
}
};
}
export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_INITIALIZER,
useFactory: () => {
const loadConfig = loadConfiguration();
return () => loadConfig().then(config => {
// Config will be available via CONFIG_DATA
});
},
multi: true
},
{
provide: CONFIG_DATA,
useFactory: loadConfiguration()
}
]
};
Multi-Environment Configuration
Structured configuration for multiple environments:
// config/base.config.ts
export const baseConfig = {
app: {
name: 'ISA Frontend',
version: '1.0.0'
},
features: {
reporting: true,
analytics: true
}
};
// config/development.config.ts
import { baseConfig } from './base.config';
export const developmentConfig = {
...baseConfig,
api: {
baseUrl: 'http://localhost:3000',
timeout: 60000
},
features: {
...baseConfig.features,
debugPanel: true,
mockData: true
}
};
// config/production.config.ts
import { baseConfig } from './base.config';
export const productionConfig = {
...baseConfig,
api: {
baseUrl: 'https://api.production.com',
timeout: 30000
},
features: {
...baseConfig.features,
debugPanel: false,
mockData: false
}
};
// app.config.ts
import { CONFIG_DATA } from '@isa/core/config';
import { developmentConfig } from './config/development.config';
import { productionConfig } from './config/production.config';
import { environment } from './environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: CONFIG_DATA,
useValue: environment.production ? productionConfig : developmentConfig
}
]
};
Testing
The library uses Jest for testing.
Running Tests
# Run tests for this library
npx nx test core-config --skip-nx-cache
# Run tests with coverage
npx nx test core-config --code-coverage --skip-nx-cache
# Run tests in watch mode
npx nx test core-config --watch
Testing Components with Config
import { TestBed } from '@angular/core/testing';
import { Config, CONFIG_DATA } from '@isa/core/config';
import { MyComponent } from './my.component';
describe('MyComponent', () => {
const mockConfig = {
api: {
baseUrl: 'http://test.example.com',
timeout: 5000
},
features: {
enabled: true
}
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: CONFIG_DATA,
useValue: mockConfig
}
]
});
});
it('should access configuration', () => {
const component = TestBed.createComponent(MyComponent).componentInstance;
const config = component.getApiConfig();
expect(config.baseUrl).toBe('http://test.example.com');
expect(config.timeout).toBe(5000);
});
});
Testing Config Service Directly
import { TestBed } from '@angular/core/testing';
import { Config, CONFIG_DATA } from '@isa/core/config';
import { z } from 'zod';
describe('Config Service', () => {
let service: Config;
const testConfig = {
app: {
name: 'Test App',
version: '1.0.0'
},
features: {
enabled: ['feature1', 'feature2']
}
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: CONFIG_DATA,
useValue: testConfig
}
]
});
service = TestBed.inject(Config);
});
it('should retrieve string value', () => {
const name = service.get('app.name', z.string());
expect(name).toBe('Test App');
});
it('should retrieve nested object', () => {
const app = service.get(
'app',
z.object({
name: z.string(),
version: z.string()
})
);
expect(app.name).toBe('Test App');
expect(app.version).toBe('1.0.0');
});
it('should retrieve array', () => {
const features = service.get('features.enabled', z.array(z.string()));
expect(features).toEqual(['feature1', 'feature2']);
});
it('should throw on validation failure', () => {
expect(() => {
service.get('app.version', z.number()); // version is string, not number
}).toThrow();
});
it('should handle missing path gracefully', () => {
const missing = service.get('nonexistent.path', z.string().optional());
expect(missing).toBeUndefined();
});
});
Example Test Suite
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from '@jest/globals';
import { Config, CONFIG_DATA } from '@isa/core/config';
import { z } from 'zod';
describe('Config Service - Advanced', () => {
let service: Config;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: CONFIG_DATA,
useValue: {
database: {
host: 'localhost',
port: 5432
},
timeout: 30000
}
}
]
});
service = TestBed.inject(Config);
});
it('should accept string path', () => {
const host = service.get('database.host', z.string());
expect(host).toBe('localhost');
});
it('should accept array path', () => {
const host = service.get(['database', 'host'], z.string());
expect(host).toBe('localhost');
});
it('should validate with schema', () => {
const port = service.get('database.port', z.number().int().positive());
expect(port).toBe(5432);
});
it('should apply default values', () => {
const retries = service.get('retries', z.number().default(3));
expect(retries).toBe(3);
});
});
Architecture Notes
Current Architecture
Simple, focused architecture:
Components/Services
↓
Config Service
↓
CONFIG_DATA (Injection Token)
↓
Application Configuration Object
Design Principles
1. Single Responsibility
The Config service has one job: retrieve configuration values by path.
Benefits:
- Easy to understand
- Easy to test
- Easy to maintain
2. Dependency Injection
Uses Angular's DI system for configuration provisioning.
Benefits:
- Easy to mock in tests
- Easy to swap implementations
- Runtime configuration injection
3. Type Safety through Validation
Zod schemas provide both runtime validation and compile-time types.
Benefits:
- Catch configuration errors early
- Type inference for better DX
- Self-documenting configuration
Implementation Details
Path Resolution
The service uses Angular CDK's coerceArray for path handling:
import { coerceArray } from '@angular/cdk/coercion';
// Handles both string and array paths
const path = coerceArray('database.host'); // ['database', 'host']
const path2 = coerceArray(['database', 'host']); // ['database', 'host']
Nested Property Access
Iterative navigation through object properties:
let result: JsonValue = this.#config;
for (const p of path) {
if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
result = result[p];
} else {
break; // Can't navigate further
}
if (result === null || result === undefined) {
break; // Path doesn't exist
}
}
Behavior:
- Stops at first non-object or null/undefined
- Returns last valid value found
- Returns undefined if path doesn't exist
Performance Considerations
- No Caching - Service doesn't cache values (intentional for simplicity)
- Path Parsing - String paths split on every access (minimal overhead)
- Validation Cost - Zod validation runs on every access (consider caching in consuming services)
Optimization Pattern:
@Injectable({ providedIn: 'root' })
export class ApiService {
#config = inject(Config);
// Cache validated config (runs once)
#apiConfig = this.#config.get('api', ApiConfigSchema);
// Access cached value (no repeated validation)
get baseUrl(): string {
return this.#apiConfig.baseUrl;
}
}
Known Limitations
1. Shallow JSON Structure Only
Current State:
- Only supports JSON-compatible data
- No support for functions, classes, symbols
- Arrays can only contain primitives
Reason: Configuration should be serializable (JSON files, API responses)
Workaround: Transform configuration after loading if complex types needed
2. No Deep Array Access
Current State:
// This works
const features = config.get('features.enabled', z.array(z.string()));
// This doesn't work (can't index into arrays by path)
const firstFeature = config.get('features.enabled[0]', z.string());
Workaround: Retrieve array first, then index in application code
3. No Path Existence Check
Current State:
- No dedicated method to check if path exists
- Must attempt retrieval and handle undefined
Workaround: Use optional schemas
const value = config.get('maybe.exists', z.string().optional());
if (value !== undefined) {
// Path exists
}
Future Enhancements
Potential improvements identified:
- Path Validation - Compile-time path checking with TypeScript
- Configuration Watching - React to configuration changes
- Configuration Merging - Merge multiple configuration sources
- Environment Validation - Validate entire config object at startup
- Path Autocomplete - IDE autocomplete for configuration paths
- Nested Array Access - Support array indexing in paths
- Configuration Schema - Define complete application configuration schema
Migration from Untyped Access
Gradual migration path:
// Old (untyped)
const url = this.#config.get('api.baseUrl'); // Type: any
// New (typed)
const url = this.#config.get('api.baseUrl', z.string()); // Type: string
Migration Strategy:
- Start with critical paths (API URLs, database config)
- Add schemas incrementally
- Use optional schemas for uncertain paths
- Eventually remove deprecated untyped access
Dependencies
Required Libraries
@angular/core- Angular framework@angular/cdk/coercion- Array coercion utilitieszod- Schema validation
Path Alias
Import from: @isa/core/config
License
Internal ISA Frontend library - not for external distribution.