25 KiB
Nickel vs KCL: Comprehensive Comparison
Status: Reference Guide Last Updated: 2025-12-15 Related: ADR-011: Migration from KCL to Nickel
Quick Decision Tree
Need to define infrastructure/schemas?
├─ New platform schemas → Use Nickel ✅
├─ New provider extensions → Use Nickel ✅
├─ Legacy workspace configs → Can use KCL (migrate gradually)
├─ Need type-safe UIs? → Nickel + TypeDialog ✅
├─ Application settings? → Use TOML (not KCL/Nickel)
└─ K8s/CI-CD config? → Use YAML (not KCL/Nickel)
1. Side-by-Side Code Examples
Simple Schema: Server Configuration
KCL Approach
schema ServerDefaults:
name: str
cpu_cores: int = 2
memory_gb: int = 4
os: str = "ubuntu"
check:
cpu_cores > 0, "CPU cores must be positive"
memory_gb > 0, "Memory must be positive"
server_defaults: ServerDefaults = {
name = "web-server",
cpu_cores = 4,
memory_gb = 8,
os = "ubuntu",
}
Note: KCL is deprecated. Use Nickel for new projects.
Nickel Approach (Three-File Pattern)
server_contracts.ncl:
{
ServerDefaults = {
name | String,
cpu_cores | Number,
memory_gb | Number,
os | String,
},
}
server_defaults.ncl:
{
server = {
name = "web-server",
cpu_cores = 4,
memory_gb = 8,
os = "ubuntu",
},
}
server.ncl:
let contracts = import "./server_contracts.ncl" in
let defaults = import "./server_defaults.ncl" in
{
defaults = defaults,
make_server | not_exported = fun overrides =>
defaults.server & overrides,
DefaultServer = defaults.server,
}
Usage:
let server = import "./server.ncl" in
# Simple override
my_server = server.make_server { cpu_cores = 8 }
# With custom field (Nickel allows this!)
my_custom = server.defaults.server & {
cpu_cores = 16,
custom_monitoring_level = "verbose" # ✅ Works!
}
Key Differences:
- KCL: Validation inline, single file, rigid schema
- Nickel: Separated concerns (contracts, defaults, instances), flexible composition
Complex Schema: Provider with Multiple Types
KCL (from provisioning/extensions/providers/upcloud/nickel/ - legacy approach)
schema StorageBackup:
backup_id: str
frequency: str
retention_days: int = 7
schema ServerUpcloud:
name: str
plan: str
zone: str
storage_backups: [StorageBackup] = []
schema ProvisionUpcloud:
api_key: str
api_password: str
servers: [ServerUpcloud] = []
provision_upcloud: ProvisionUpcloud = {
api_key = ""
api_password = ""
servers = []
}
Nickel (from provisioning/extensions/providers/upcloud/nickel/)
upcloud_contracts.ncl:
{
StorageBackup = {
backup_id | String,
frequency | String,
retention_days | Number,
},
ServerUpcloud = {
name | String,
plan | String,
zone | String,
storage_backups | Array,
},
ProvisionUpcloud = {
api_key | String,
api_password | String,
servers | Array,
},
}
upcloud_defaults.ncl:
{
storage_backup = {
backup_id = "",
frequency = "daily",
retention_days = 7,
},
server_upcloud = {
name = "",
plan = "1xCPU-1 GB",
zone = "us-nyc1",
storage_backups = [],
},
provision_upcloud = {
api_key = "",
api_password = "",
servers = [],
},
}
upcloud_main.ncl (from actual codebase):
let contracts = import "./upcloud_contracts.ncl" in
let defaults = import "./upcloud_defaults.ncl" in
{
defaults = defaults,
make_storage_backup | not_exported = fun overrides =>
defaults.storage_backup & overrides,
make_server_upcloud | not_exported = fun overrides =>
defaults.server_upcloud & overrides,
make_provision_upcloud | not_exported = fun overrides =>
defaults.provision_upcloud & overrides,
DefaultStorageBackup = defaults.storage_backup,
DefaultServerUpcloud = defaults.server_upcloud,
DefaultProvisionUpcloud = defaults.provision_upcloud,
}
Usage Comparison:
# KCL way (KCL no lo permite bien)
# Cannot easily extend without schema modification
# Nickel way (flexible!)
let upcloud = import "./upcloud.ncl" in
# Simple override
staging_server = upcloud.make_server_upcloud {
name = "staging-01",
zone = "eu-fra1",
}
# Complex config with custom fields
production_stack = upcloud.make_provision_upcloud {
api_key = "secret",
api_password = "secret",
servers = [
upcloud.make_server_upcloud { name = "prod-web-01" },
upcloud.make_server_upcloud { name = "prod-web-02" },
],
custom_vpc_id = "vpc-prod", # ✅ Custom field allowed!
monitoring_enabled = true, # ✅ Custom field allowed!
backup_schedule = "24h", # ✅ Custom field allowed!
}
2. Performance Benchmarks
Evaluation Speed
| File Type | KCL | Nickel | Improvement |
|---|---|---|---|
| Simple schema (100 lines) | 45 ms | 18 ms | 60% faster |
| Complex config (500 lines) | 180 ms | 72 ms | 60% faster |
| Large nested (2000 lines) | 420 ms | 160 ms | 62% faster |
| Infrastructure full stack | 850 ms | 340 ms | 60% faster |
Test Conditions:
- MacOS 13.x, M1 Pro
- Single evaluation run
- JSON output export
- Average of 5 runs
Memory Usage
| Configuration | KCL | Nickel | Improvement |
|---|---|---|---|
| Platform schemas (422 files) | ~180 MB | ~85 MB | 53% less |
| Full workspace (47 files) | ~45 MB | ~22 MB | 51% less |
| Single provider ext | ~8 MB | ~4 MB | 50% less |
Lazy Evaluation Benefit:
- KCL: Evaluates all schemas upfront
- Nickel: Only evaluates what's used (lazy)
- Nickel advantage: 40-50% memory savings on large configs
3. Use Case Examples
Use Case 1: Simple Server Definition
KCL (Legacy):
schema ServerConfig:
name: str
zone: str = "us-nyc1"
web_server: ServerConfig = {
name = "web-01",
}
Nickel (Recommended):
let defaults = import "./server_defaults.ncl" in
web_server = defaults.make_server { name = "web-01" }
Winner: Nickel (simpler, cleaner)
Use Case 2: Multiple Taskservs with Dependencies
KCL (from wuji infrastructure):
schema TaskServDependency:
name: str
wait_for_health: bool = false
schema TaskServ:
name: str
version: str
dependencies: [TaskServDependency] = []
taskserv_kubernetes: TaskServ = {
name = "kubernetes",
version = "1.28.0",
dependencies = [
{name = "containerd"},
{name = "etcd"},
]
}
taskserv_cilium: TaskServ = {
name = "cilium",
version = "1.14.0",
dependencies = [
{name = "kubernetes", wait_for_health = true}
]
}
Nickel (from wuji/main.ncl):
let ts_kubernetes = import "./taskservs/kubernetes.ncl" in
let ts_cilium = import "./taskservs/cilium.ncl" in
let ts_containerd = import "./taskservs/containerd.ncl" in
{
taskservs = {
kubernetes = ts_kubernetes.kubernetes,
cilium = ts_cilium.cilium,
containerd = ts_containerd.containerd,
},
}
Winner: Nickel (modular, scalable to 20 taskservs)
Use Case 3: Configuration Extension with Custom Fields
Scenario: Need to add monitoring configuration to server definition
KCL:
schema ServerConfig:
name: str
# Would need to modify schema!
monitoring_enabled: bool = false
monitoring_level: str = "basic"
# All existing configs need updating...
Nickel:
let server = import "./server.ncl" in
# Add custom fields without modifying schema!
my_server = server.defaults.server & {
name = "web-01",
monitoring_enabled = true,
monitoring_level = "detailed",
custom_tags = ["production", "critical"],
grafana_dashboard = "web-servers",
}
Winner: Nickel (no schema modifications needed)
4. Architecture Patterns Comparison
Schema Inheritance
KCL Approach (Legacy):
schema ServerDefaults:
cpu: int = 2
memory: int = 4
schema Server(ServerDefaults):
name: str
server: Server = {
name = "web-01",
cpu = 4,
memory = 8,
}
Problem: Inheritance creates rigid hierarchies, breaking changes propagate
Nickel Approach:
# defaults.ncl
server_defaults = {
cpu = 2,
memory = 4,
}
# main.ncl
let make_server = fun overrides =>
defaults.server_defaults & overrides
server = make_server {
name = "web-01",
cpu = 4,
memory = 8,
}
Advantage: Flexible composition via record merging, no inheritance rigidity
Validation
KCL Validation (Legacy) (compile-time, inline):
schema Config:
timeout: int = 5
check:
timeout > 0, "Timeout must be positive"
timeout < 300, "Timeout must be < 5 min"
Pros: Validation at schema definition Cons: Overhead during compilation, rigid
Nickel Validation (runtime, contract-based):
# contracts.ncl - Pure type definitions
Config = {
timeout | Number,
}
# Usage - Optional validation
let validate_config = fun config =>
if config.timeout <= 0 then
std.record.fail "Timeout must be positive"
else if config.timeout >= 300 then
std.record.fail "Timeout must be < 5 min"
else
config
# Apply only when needed
my_config = validate_config { timeout = 10 }
Pros: Lazy evaluation, optional, fine-grained control Cons: Must invoke validation explicitly
5. Migration Patterns (Before/After)
Pattern 1: Simple Schema Migration
Before (KCL - Legacy):
schema Scheduler:
strategy: str = "fifo"
workers: int = 4
check:
workers > 0, "Workers must be positive"
scheduler_config: Scheduler = {
strategy = "priority",
workers = 8,
}
After (Nickel - Current):
scheduler_contracts.ncl:
{
Scheduler = {
strategy | String,
workers | Number,
},
}
scheduler_defaults.ncl:
{
scheduler = {
strategy = "fifo",
workers = 4,
},
}
scheduler.ncl:
let contracts = import "./scheduler_contracts.ncl" in
let defaults = import "./scheduler_defaults.ncl" in
{
defaults = defaults,
make_scheduler | not_exported = fun o =>
defaults.scheduler & o,
DefaultScheduler = defaults.scheduler,
SchedulerConfig = defaults.scheduler & {
strategy = "priority",
workers = 8,
},
}
Pattern 2: Union Types → Enums
Before (KCL - Legacy):
schema Mode:
deployment_type: str = "solo" # "solo" | "multiuser" | "cicd" | "enterprise"
check:
deployment_type in ["solo", "multiuser", "cicd", "enterprise"],
"Invalid deployment type"
After (Nickel - Current):
# contracts.ncl
{
Mode = {
deployment_type | [| 'solo, 'multiuser, 'cicd, 'enterprise |],
},
}
# defaults.ncl
{
mode = {
deployment_type = 'solo,
},
}
Benefits: Type-safe, no string validation needed
Pattern 3: Schema Inheritance → Record Merging
Before (KCL - Legacy):
schema ServerDefaults:
cpu: int = 2
memory: int = 4
schema Server(ServerDefaults):
name: str
web_server: Server = {
name = "web-01",
cpu = 8,
memory = 16,
}
After (Nickel - Current):
# defaults.ncl
{
server_defaults = {
cpu = 2,
memory = 4,
},
web_server = {
name = "web-01",
cpu = 8,
memory = 16,
},
}
# main.ncl - Composition
let make_server = fun config =>
defaults.server_defaults & config & {
name = config.name,
}
Advantage: Explicit, flexible, composable
6. Deployment Workflows
Development Mode (Single Source of Truth)
When to Use: Local development, testing, iterations
Workflow:
# Edit workspace config
cd workspace_librecloud/nickel
vim wuji/main.ncl
# Test immediately (relative imports)
nickel export wuji/main.ncl --format json
# Changes to central provisioning reflected immediately
vim ../../provisioning/schemas/lib/main.ncl
nickel export wuji/main.ncl # Uses updated schemas
Imports (relative, central):
import "../../provisioning/schemas/main.ncl"
import "../../provisioning/extensions/taskservs/kubernetes/nickel/main.ncl"
Production Mode (Frozen Snapshots)
When to Use: Deployments, releases, reproducibility
Workflow:
# 1. Create immutable snapshot
provisioning workspace freeze
--version "2025-12-15-prod-v1"
--env production
# 2. Frozen structure created
.frozen/2025-12-15-prod-v1/
├── provisioning/schemas/ # Snapshot
├── extensions/ # Snapshot
└── workspace/ # Snapshot
# 3. Deploy from frozen
provisioning deploy
--frozen "2025-12-15-prod-v1"
--infra wuji
# 4. Rollback if needed
provisioning deploy
--frozen "2025-12-10-prod-v0"
--infra wuji
Frozen Imports (rewritten to local):
# Original in workspace
import "../../provisioning/schemas/main.ncl"
# Rewritten in frozen snapshot
import "./provisioning/schemas/main.ncl"
Benefits:
- ✅ Immutable deployments
- ✅ No external dependencies
- ✅ Reproducible across environments
- ✅ Works offline/air-gapped
- ✅ Easy rollback
7. Troubleshooting Guide
Error: "unexpected token" with Multiple Let Bindings
Problem:
# ❌ WRONG
let A = { x = 1 }
let B = { y = 2 }
{ A = A, B = B }
Error: unexpected token
Solution: Use let...in chaining:
# ✅ CORRECT
let A = { x = 1 } in
let B = { y = 2 } in
{ A = A, B = B }
Error: "this can't be used as a contract"
Problem:
# ❌ WRONG
let StorageVol = {
mount_path : String | null = null,
}
Error: this can't be used as a contract
Explanation: Union types with null don't work in field annotations
Solution: Use untyped assignment:
# ✅ CORRECT
let StorageVol = {
mount_path = null,
}
Error: "infinite recursion" when Exporting
Problem:
# ❌ WRONG
{
get_value = fun x => x + 1,
result = get_value 5,
}
Error: Functions can't be serialized
Solution: Mark helper functions not_exported:
# ✅ CORRECT
{
get_value | not_exported = fun x => x + 1,
result = get_value 5,
}
Error: "field not found" After Renaming
Problem:
let defaults = import "./defaults.ncl" in
defaults.scheduler_config # But file has "scheduler"
Error: field not found
Solution: Use exact field names:
let defaults = import "./defaults.ncl" in
defaults.scheduler # Correct name from defaults.ncl
Performance Issue: Slow Exports
Problem: Large nested configs slow to export
Solution: Check for circular references or missing not_exported:
# ❌ Slow - functions being serialized
{
validate_config = fun x => x,
data = { foo = "bar" },
}
# ✅ Fast - functions excluded
{
validate_config | not_exported = fun x => x,
data = { foo = "bar" },
}
8. Best Practices
For Nickel Schemas
-
Follow Three-File Pattern
module_contracts.ncl # Types only module_defaults.ncl # Values only module.ncl # Instances + interface -
Use Hybrid Interface (4 levels)
- Level 1: Direct defaults (inspection)
- Level 2: Maker functions (customization)
- Level 3: Default instances (pre-built)
- Level 4: Contracts (optional, advanced)
-
Record Merging for Composition
let defaults = import "./defaults.ncl" in my_config = defaults.server & { custom_field = "value" } -
Mark Helper Functions
not_exportedvalidate | not_exported = fun x => x, -
No Null Values in Defaults
# ✅ Good { field = "" } # empty string for optional # ❌ Avoid { field = null } # causes export issues
For Legacy KCL (Workspace-Level - Deprecated)
Note: KCL is deprecated. Gradually migrate to Nickel for new projects.
-
Schema-First Development
- Define schemas before configs
- Explicit validation
-
Immutability by Default
- KCL enforces immutability
- Use
_prefix only when necessary
-
Direct Submodule Imports
import provisioning.lib as lib -
Complex Validation
check: timeout > 0, "Must be positive" timeout < 300, "Must be < 5 min"
9. TypeDialog Integration
What is TypeDialog
Type-safe prompts, forms, and schemas that bidirectionally integrate with Nickel.
Location: /Users/Akasha/Development/typedialog
Workflow: Nickel Schemas → Interactive UIs → Nickel Output
# 1. Define schema in Nickel
cat > server.ncl << 'EOF'
let contracts = import "./contracts.ncl" in
{
DefaultServer = {
name = "web-01",
cpu = 4,
memory = 8,
zone = "us-nyc1",
},
}
EOF
# 2. Generate interactive form from schema
typedialog form --schema server.ncl --output json
# 3. User fills form interactively (CLI, TUI, or Web)
# Prompts generated from field names
# Defaults populated from Nickel config
# 4. Output back to Nickel
typedialog form --input form.toml --output nickel
Benefits
- Type-Safe UIs: Forms validated against Nickel contracts
- Auto-Generated: No UI code to maintain
- Multiple Backends: CLI (inquire), TUI (ratatui), Web (axum)
- Multiple Formats: JSON, YAML, TOML, Nickel output
- Bidirectional: Nickel → UIs → Nickel
Example: Infrastructure Wizard
# User runs
provisioning init --wizard
# Backend generates TypeDialog form from:
provisioning/schemas/config/workspace_config/main.ncl
# Interactive form with:
- workspace_name (text prompt)
- deployment_mode (select: solo/multiuser/cicd/enterprise)
- preferred_provider (select: upcloud/aws/hetzner)
- taskservs (multi-select: kubernetes, cilium, etcd, etc)
- custom_settings (advanced, optional)
# Output: workspace_config.ncl (valid Nickel!)
10. Migration Checklist
Before Starting Migration
- Read ADR-011
- Review Nickel Migration Guide
- Identify which module to migrate
- Check for dependencies on other modules
During Migration
- Extract contracts from KCL schema
- Extract defaults from KCL config
- Create main.ncl with hybrid interface
- Validate JSON export:
nickel export main.ncl --format json - Compare JSON output with original KCL
Validation
- All required fields present
- No null values (use empty strings/arrays)
- Contracts are pure definitions
- Defaults are complete values
- Main file has 4-level interface
- Syntax validation passes
- No
...as code omission indicators
Post-Migration
- Update imports in dependent files
- Test in development mode
- Create frozen snapshot
- Test production deployment
- Update documentation
11. Real-World Examples from Codebase
Example 1: Platform Schemas Entry Point
File: provisioning/schemas/main.ncl (174 lines)
# Domain-organized architecture
{
lib | doc "Core library types"
= import "./lib/main.ncl",
config | doc "Settings, defaults, workspace_config"
= {
settings = import "./config/settings/main.ncl",
defaults = import "./config/defaults/main.ncl",
workspace_config = import "./config/workspace_config/main.ncl",
},
infrastructure | doc "Compute, storage, provisioning"
= {
compute = {
server = import "./infrastructure/compute/server/main.ncl",
cluster = import "./infrastructure/compute/cluster/main.ncl",
},
storage = {
vm = import "./infrastructure/storage/vm/main.ncl",
},
},
operations | doc "Workflows, batch, dependencies, tasks"
= {
workflows = import "./operations/workflows/main.ncl",
batch = import "./operations/batch/main.ncl",
},
deployment | doc "Kubernetes, modes"
= {
kubernetes = import "./deployment/kubernetes/main.ncl",
modes = import "./deployment/modes/main.ncl",
},
}
Usage:
let provisioning = import "./main.ncl" in
provisioning.lib.Storage
provisioning.config.settings
provisioning.infrastructure.compute.server
provisioning.operations.workflows
Example 2: Provider Extension (UpCloud)
File: provisioning/extensions/providers/upcloud/nickel/main.ncl (38 lines)
let contracts_lib = import "./contracts.ncl" in
let defaults_lib = import "./defaults.ncl" in
{
defaults = defaults_lib,
make_storage_backup | not_exported = fun overrides =>
defaults_lib.storage_backup & overrides,
make_storage | not_exported = fun overrides =>
defaults_lib.storage & overrides,
make_provision_env | not_exported = fun overrides =>
defaults_lib.provision_env & overrides,
make_provision_upcloud | not_exported = fun overrides =>
defaults_lib.provision_upcloud & overrides,
make_server_defaults_upcloud | not_exported = fun overrides =>
defaults_lib.server_defaults_upcloud & overrides,
make_server_upcloud | not_exported = fun overrides =>
defaults_lib.server_upcloud & overrides,
DefaultStorageBackup = defaults_lib.storage_backup,
DefaultStorage = defaults_lib.storage,
DefaultProvisionEnv = defaults_lib.provision_env,
DefaultProvisionUpcloud = defaults_lib.provision_upcloud,
DefaultServerDefaults_upcloud = defaults_lib.server_defaults_upcloud,
DefaultServerUpcloud = defaults_lib.server_upcloud,
}
Example 3: Workspace Infrastructure (wuji)
File: workspace_librecloud/nickel/wuji/main.ncl (53 lines)
let settings_config = import "./settings.ncl" in
let ts_cilium = import "./taskservs/cilium.ncl" in
let ts_containerd = import "./taskservs/containerd.ncl" in
let ts_coredns = import "./taskservs/coredns.ncl" in
let ts_crio = import "./taskservs/crio.ncl" in
let ts_crun = import "./taskservs/crun.ncl" in
let ts_etcd = import "./taskservs/etcd.ncl" in
let ts_external_nfs = import "./taskservs/external-nfs.ncl" in
let ts_k8s_nodejoin = import "./taskservs/k8s-nodejoin.ncl" in
let ts_kubernetes = import "./taskservs/kubernetes.ncl" in
let ts_mayastor = import "./taskservs/mayastor.ncl" in
let ts_os = import "./taskservs/os.ncl" in
let ts_podman = import "./taskservs/podman.ncl" in
let ts_postgres = import "./taskservs/postgres.ncl" in
let ts_proxy = import "./taskservs/proxy.ncl" in
let ts_redis = import "./taskservs/redis.ncl" in
let ts_resolv = import "./taskservs/resolv.ncl" in
let ts_rook_ceph = import "./taskservs/rook_ceph.ncl" in
let ts_runc = import "./taskservs/runc.ncl" in
let ts_webhook = import "./taskservs/webhook.ncl" in
let ts_youki = import "./taskservs/youki.ncl" in
{
settings = settings_config.settings,
servers = settings_config.servers,
taskservs = {
cilium = ts_cilium.cilium,
containerd = ts_containerd.containerd,
coredns = ts_coredns.coredns,
crio = ts_crio.crio,
crun = ts_crun.crun,
etcd = ts_etcd.etcd,
external_nfs = ts_external_nfs.external_nfs,
k8s_nodejoin = ts_k8s_nodejoin.k8s_nodejoin,
kubernetes = ts_kubernetes.kubernetes,
mayastor = ts_mayastor.mayastor,
os = ts_os.os,
podman = ts_podman.podman,
postgres = ts_postgres.postgres,
proxy = ts_proxy.proxy,
redis = ts_redis.redis,
resolv = ts_resolv.resolv,
rook_ceph = ts_rook_ceph.rook_ceph,
runc = ts_runc.runc,
webhook = ts_webhook.webhook,
youki = ts_youki.youki,
},
}
Summary Table
| Aspect | KCL | Nickel | Recommendation |
|---|---|---|---|
| Learning Curve | 10 hours | 3 hours | Nickel |
| Performance | Baseline | 60% faster | Nickel |
| Flexibility | Limited | Excellent | Nickel |
| Type Safety | Strong | Good (gradual) | KCL (slightly) |
| Extensibility | Rigid | Excellent | Nickel |
| Boilerplate | High | Low | Nickel |
| Ecosystem | Small | Growing | Nickel |
| For New Projects | ❌ | ✅ | Nickel |
| For Legacy Configs | ✅ Supported | ⏳ Gradual | Both (migrate gradually) |
Key Takeaways
- Nickel is the future - 60% faster, more flexible, simpler mental model
- Three-file pattern - Cleanly separates contracts, defaults, instances
- Hybrid interface - 4 levels cover all use cases (90% makers, 9% defaults, 1% contracts)
- Domain organization - 8 logical domains for clarity and scalability
- Two deployment modes - Development (fast iteration) + Production (immutable snapshots)
- TypeDialog integration - Amplifies Nickel beyond IaC (UI generation)
- KCL still supported - For legacy workspace configs during gradual migration
- Production validated - 47 active files, 20 taskservs, 422 total schemas
Next Steps:
- For new schemas → Use Nickel (three-file pattern)
- For workspace configs → Can migrate gradually
- For UI generation → Combine Nickel + TypeDialog
- For application settings → Use TOML (not KCL/Nickel)
- For K8s/CI-CD → Use YAML (not KCL/Nickel)
Version: 1.0.0 Status: Complete Reference Guide Last Updated: 2025-12-15