UP (Unified Properties)

A Human-Friendly Data Serialization Format

View the Project on GitHub uplang/spec

UP templating provides declarative configuration composition using the same !type annotation syntax as the rest of UP. No string substitution, no logic, just clean data composition.

Syntax

Template directives use !annotation syntax, consistent with UP’s type system:

Variables

vars {
  app_name MyApp
  port!int 8080
  region us-west-2
}

# Reference with $ prefix
server {
  port $vars.port
  region $vars.region
}

Base Configuration

config!base base.up

Load and inherit from a base configuration file.

Overlays

server!overlay {
  host production.example.com
  replicas!int 10
}

Declaratively merge this block with existing configuration.

Includes

features!include [
  features/beta.up
  features/ha.up
]

Include and merge multiple files.

Patches

scaling!patch {
  server.replicas!int 20
  features.beta!bool true
}

Apply targeted modifications using path notation.

Merge Strategy

options!merge {
  strategy deep
  list_strategy append
}

Configure how blocks and lists are merged.

Complete Example

base.up:

vars {
  app_name MyApp
  default_port!int 8080
}

app_name $vars.app_name
version 1.0.0

server {
  host 0.0.0.0
  port $vars.default_port
  replicas!int 2
}

database {
  driver postgres
  pool_size!int 20
}

features {
  new_ui!bool false
  beta_api!bool false
}

production.up:

config!base base.up

vars {
  prod_host production.example.com
}

server!overlay {
  host $vars.prod_host
  port!int 443
  replicas!int 10
  tls_enabled!bool true
}

database!overlay {
  host db.production.example.com
  pool_size!int 100
  ssl_enabled!bool true
}

features!overlay {
  new_ui!bool true
  analytics!bool true
}

Result after processing:

app_name MyApp
version 1.0.0
server {
  host production.example.com
  port 443
  replicas 10
  tls_enabled true
}
database {
  driver postgres
  pool_size 100
  host db.production.example.com
  ssl_enabled true
}
features {
  new_ui true
  beta_api false
  analytics true
}

Template Annotations

Annotation Purpose Usage
!base Inherit configuration config!base base.up
!overlay Merge block declaratively server!overlay { }
!include Include multiple files mods!include [file1, file2]
!patch Apply targeted changes fixes!patch { path value }
!merge Configure merge behavior opts!merge { strategy deep }

Variable References

Use $vars.path to reference variables:

vars {
  environment production
  config {
    timeout!dur 30s
    retries!int 3
  }
}

deployment {
  env $vars.environment
  timeout $vars.config.timeout
  retries $vars.config.retries
}

Merge Strategies

Deep Merge (Default)

Blocks are merged recursively:

# Base
server { host localhost, port!int 8080 }

# Overlay
server!overlay { host prod.com, tls!bool true }

# Result
server { host prod.com, port 8080, tls true }

List Strategies

append (default):

tags [web, api]
tags!overlay [production]
# Result: [web, api, production]

replace:

options!merge { list_strategy replace }
tags!overlay [production, v2]
# Result: [production, v2]

unique:

options!merge { list_strategy unique }
tags [web, api]
tags!overlay [api, production]
# Result: [web, api, production]

Patching

Path-based modifications:

scaling!patch {
  server.replicas!int 20
  server.cpu 4000m
  features.beta!bool true
  items[*].enabled!bool true
}

Multi-Environment Setup

Structure:

config/
├── base.up              # Common settings
├── development.up       # Dev overrides
├── staging.up          # Staging overrides
├── production.up       # Production config
└── features/
    ├── beta.up
    └── ha.up

base.up:

vars {
  app_name MyApp
  default_replicas!int 2
}

app_name $vars.app_name
server {
  replicas $vars.default_replicas
}

development.up:

config!base base.up

server!overlay {
  host localhost
  debug!bool true
}

production.up:

config!base base.up

server!overlay {
  host production.example.com
  replicas!int 10
  tls_enabled!bool true
}

production-with-beta.up:

config!base production.up

features!include [
  features/beta.up
]

CLI Usage

# Process template
up template process -i production.up -o output.up

# Validate template
up template validate -i production.up

# Output as JSON
up template process -i production.up --json --pretty

Why This Design?

Consistent with UP

port!int 8080          # Type annotation
config!base base.up  # Template annotation

Same !annotation pattern everywhere.

No Reserved Keys

Any key can be used with template annotations:

base!base base.up
foundation!base base.up
parent!base base.up

Clear Variable Syntax

$vars.port              # Clear it's a variable reference
server.replicas!int 10  # Clear it's a type annotation

Comparison

Traditional (Helm):

replicas: 
tls: 

Problems: String substitution, logic, type-unsafe, mixed languages.

UP:

config!base base.up
server!overlay {
  replicas!int 10
  tls_enabled!bool true
}

Benefits: Declarative, type-safe, pure UP syntax, no logic.

Processing Pipeline

1. Load and parse all documents (base, includes, current)
   ↓
2. Extract variables from ALL documents (order-independent)
   ↓
3. Merge documents (base → includes → current)
   ↓
4. Apply overlays (blocks with !overlay)
   ↓
5. Apply patches (blocks with !patch)
   ↓
6. Iteratively resolve variables until convergence
   ↓
7. Output final configuration

Iterative Variable Resolution

Variables are resolved iteratively rather than sequentially. This means:

Example:

vars {
  # These reference each other - order doesn't matter
  region us-west-2
  environment production

  # Builds from above (even though defined first)
  full_name $vars.environment-$vars.region

  # Uses full_name (defined above)
  deployment_id v1-$vars.full_name

  # Multi-level reference
  tag service-$vars.deployment_id
}

# All resolve correctly:
# full_name = "production-us-west-2"
# deployment_id = "v1-production-us-west-2"
# tag = "service-v1-production-us-west-2"

String Interpolation:

vars {
  host example.com
  port!int 443
  protocol https

  # Multiple variables in one string
  url $vars.protocol://$vars.host:$vars.port
  health_check $vars.url/health
}

# Result:
# url = "https://example.com:443"
# health_check = "https://example.com:443/health"

Language Implementation

Go

import up "github.com/uplang/spec/parsers/go/src"

engine := up.NewTemplateEngine()
doc, err := engine.ProcessTemplate("config.up")

JavaScript

const up = require('@up-lang/parser');
const doc = await up.template('config.up');

Python

import up
doc = up.process_template('config.up')

Examples

All examples are in examples/templates/:

Process them:

up template process -i examples/templates/production.up

Multi-Document Files

UP supports multiple “documents” in a single file using comment-based separators. This is useful for:

Syntax

Use any comment line as a separator (common convention: # ---):

# base configuration
vars { app_name MyApp }
server { host localhost, port!int 8080 }

# ---

# development overlay
config!base base.up
server!overlay { debug!bool true }

# ---

# production overlay
config!base base.up
server!overlay { host prod.com, tls!bool true }

Parser Behavior

Semantic Analysis

Tooling can parse comment-separated documents and warn about:

Ordering Issues:

# BAD: overlay before base is loaded
server!overlay { tls!bool true }

# ---

config!base base.up

Undefined References:

database { host $vars.db_host }  # Warning: vars.db_host not defined

# ---

vars { api_host api.example.com }  # Different doc, doesn't define db_host

Circular Dependencies:

# a.up
config!base b.up

# ---

# b.up
config!base a.up

A linter can detect these by:

  1. Splitting on separator comments
  2. Checking document order
  3. Analyzing references across documents
  4. Warning about potential issues

Example

config-all.up:

# Base configuration - common settings
vars {
  app_name MyApp
  default_port!int 8080
}

app_name $vars.app_name
version 1.0.0

server {
  host 0.0.0.0
  port $vars.default_port
  replicas!int 2
}

# ---

# Development configuration
config!base base.up

vars {
  dev_host localhost
}

server!overlay {
  host $vars.dev_host
  debug!bool true
  replicas!int 1
}

# ---

# Production configuration
config!base base.up

vars {
  prod_host production.example.com
}

server!overlay {
  host $vars.prod_host
  replicas!int 10
  tls_enabled!bool true
}

features!include [
  features/monitoring.up
  features/high-availability.up
]

Process a specific document (by line range, index, or extraction):

# Extract and process just the production config
sed -n '/# Production/,/^# ---$/p' config-all.up | up template process

# Or use future up multi-doc support:
up template process -i config-all.up --doc 2  # 0-indexed

Design Principles

  1. Use UP syntax - No new language
  2. Type-safe - Variables are UP values
  3. Declarative - Describe what, not how
  4. Composable - Layer configurations
  5. Predictable - Clear merge semantics
  6. No logic - Pure data transformation
  7. Parser-agnostic separators - Multi-doc via comments only

UP vs YAML Anchors

UP’s templating system (!base, !overlay, !include, !patch, vars) provides complete DRY support that is far superior to YAML anchors.

YAML Anchor Problems

# YAML anchors are fragile and limited
defaults: &defaults
  adapter: postgres
  host: localhost
  pool_size: 20

development:
  <<: *defaults           # Merge is shallow
  database: dev_db

production:
  <<: *defaults
  database: prod_db
  host: prod.example.com  # Easy to forget overrides, no validation
  pool_size: 100          # No type checking

Issues with YAML anchors:

  1. Shallow merge only - No deep merge for nested structures
  2. No validation or type safety - Easy to introduce type errors
  3. Anchors must be defined before use - Order-dependent
  4. No file composition - Everything in one file or complex includes
  5. No targeted patching - Can’t modify nested values easily
  6. Confusing syntax - &anchor, *alias, <<: merge key
  7. Indentation traps - Easy to break with wrong indentation
  8. No variable interpolation - Can’t reference values dynamically
  9. Limited reusability - Anchors are file-scoped only

UP’s Superior Approach

Example: Same functionality, better design

base.up - Clean, typed, reusable:

vars {
  adapter postgres
  host localhost
  pool_size!int 20
}

database {
  adapter $vars.adapter
  host $vars.host
  pool_size $vars.pool_size
}

development.up - Declarative composition:

config!base base.up

database!overlay {
  name dev_db
  debug!bool true
}

production.up - Type-safe, validated:

config!base base.up

vars!overlay {
  host prod.example.com
  pool_size!int 100
}

database!overlay {
  name prod_db
  host $vars.host
  pool_size $vars.pool_size
  ssl_enabled!bool true
}

production-ha.up - Multi-file composition:

config!base production.up

features!include [
  features/high-availability.up
  features/monitoring.up
  features/backup.up
]

scaling!patch {
  database.pool_size!int 200
  database.replicas!int 3
  cache.enabled!bool true
}

UP Advantages Over YAML Anchors

Feature YAML Anchors UP Templating
Merge depth Shallow only Deep by default
Type safety None Full !type annotations
File composition Limited !base, !include
Targeted patching No !patch with paths
Variable interpolation No $vars.path
Order independence No (anchor must be first) Yes (iterative resolution)
Merge strategies Fixed shallow Configurable (!merge)
Validation No Type-checked
Syntax clarity &, *, <<: confusing !annotation consistent
Indentation safety Error-prone Robust
Cross-file reuse No Yes

Patterns Impossible or Fragile in YAML

1. Deep merge of nested configuration:

YAML (requires manual repetition):

defaults: &defaults
  server:
    timeout: 30
    retries: 3
  database:
    pool_size: 20

# Can't deep merge - must repeat entire structure
production:
  server:
    timeout: 30      # Repeated
    retries: 3       # Repeated
    host: prod.com   # New value
  database:
    pool_size: 100   # Changed
    # Missing timeout and retries - error prone!

UP (clean deep merge):

# base.up
server {
  timeout!dur 30s
  retries!int 3
}

database {
  pool_size!int 20
}

# production.up
config!base base.up

server!overlay {
  host prod.example.com
}

database!overlay {
  pool_size!int 100
}
# Result: All base values preserved, only specified values changed

2. Multi-file composition:

YAML (limited):

# No native multi-file composition
# Must use external tools like Helm, Kustomize

UP (native):

config!base common.up

features!include [
  team/feature-a.up
  team/feature-b.up
  vendor/integration-c.up
]

3. Targeted modifications of deeply nested values:

YAML (verbose and error-prone):

# Must repeat entire structure to change one nested value
app:
  server:
    cache:
      redis:
        host: redis.prod.com  # Only want to change this
        port: 6379            # But must repeat all siblings
        pool_size: 50
        timeout: 5000

UP (surgical):

config!base base.up

fixes!patch {
  app.server.cache.redis.host redis.prod.com
}
# Only the targeted value changes, everything else preserved

4. Type-safe variable composition:

YAML (no type safety):

defaults: &defaults
  port: "8080"        # Oops, string instead of number
  replicas: true      # Oops, bool instead of number

production:
  <<: *defaults
  # Inherits type errors!

UP (type-checked):

vars {
  port!int 8080       # Type enforced
  replicas!int 3      # Type enforced
}

server {
  port $vars.port
  replicas $vars.replicas
}
# Type errors caught at parse/validation time

5. Order-independent variable resolution:

YAML (order matters):

# Must define anchor before use
base: &base
  url: &base_url "http://localhost"
  
app:
  <<: *base
  full_url: *base_url  # Can only alias, not compose

UP (order-independent):

vars {
  # These can reference each other in any order
  full_url $vars.protocol://$vars.host:$vars.port
  protocol https
  host example.com
  port!int 443
  health_check $vars.full_url/health
}
# Iterative resolution handles any order

6. Environment-specific composition with validation:

YAML (no validation):

# base.yaml
defaults: &defaults
  replicas: 2

# production.yaml  
production:
  <<: *defaults
  replicas: "many"  # Type error not caught!

UP (validated):

# base.up
server {
  replicas!int 2
}

# production.up
config!base base.up

server!overlay {
  replicas!int many  # Parse error: "many" is not an integer
}

Why UP is Better

Declarative, not repetitive:

Type-safe by design:

Composable across files:

Clear, predictable semantics:

No indentation hell:

Conclusion: For any non-trivial configuration, UP’s templating system provides superior DRY capabilities, type safety, and maintainability compared to YAML anchors.

No templating hell. Just clean, composable configuration.