Skip to content

Custom Transformations

Argos provides powerful transformation capabilities through the map() function, allowing you to convert command-line string arguments into any custom type your application needs. This enables type-safe CLIs with rich domain objects.

Basic Transformations

Use map() to transform string arguments into custom types:

import java.io.File
import java.net.URL

class MyApp : Arguments() {
    // Transform to File objects
    val configFile by option("--config")
        .map("readable config file") { path ->
            path?.let { File(it).takeIf { f -> f.exists() && f.canRead() } }
        }

    // Transform to URL objects
    val endpoint by option("--endpoint")
        .map("valid URL") { url ->
            url?.let {
                try { URL(it) } catch (e: Exception) { null }
            }
        }

    // Transform to custom data classes
    val coordinates by option("--coords")
        .map("coordinates in lat,lng format") { coordStr ->
            coordStr?.let {
                val parts = it.split(",")
                if (parts.size == 2) {
                    val lat = parts[0].toDoubleOrNull()
                    val lng = parts[1].toDoubleOrNull()
                    if (lat != null && lng != null) {
                        Coordinates(lat, lng)
                    } else null
                } else null
            }
        }
}

data class Coordinates(val latitude: Double, val longitude: Double)

Usage Examples

myapp --config /path/to/config.json \
      --endpoint https://api.example.com \
      --coords 37.7749,-122.4194

Error Handling in Transformations

Transformations should return null for invalid inputs to trigger clear error messages:

class MyApp : Arguments() {
    val port by option("--port")
        .map("port number (1-65535)") { portStr ->
            portStr?.toIntOrNull()?.takeIf { it in 1..65535 }
        }

    val percentage by option("--load")
        .map("percentage (0-100)") { percentStr ->
            percentStr?.toDoubleOrNull()?.takeIf { it in 0.0..100.0 }
        }

    val duration by option("--timeout")
        .map("duration in seconds") { durationStr ->
            durationStr?.toLongOrNull()?.takeIf { it > 0 }
        }
}

Error Messages

When transformations return null, users see clear error messages:

myapp --port 99999
# Error: Invalid value for --port: '99999' is not a port number (1-65535)

myapp --load 150
# Error: Invalid value for --load: '150' is not a percentage (0-100)

myapp --timeout -5
# Error: Invalid value for --timeout: '-5' is not a duration in seconds

Advanced Transformations

Date and Time Parsing

import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException

class TimeApp : Arguments() {
    val startDate by option("--start-date")
        .map("date in YYYY-MM-DD format") { dateStr ->
            dateStr?.let {
                try {
                    LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE)
                } catch (e: DateTimeParseException) { null }
            }
        }

    val timestamp by option("--timestamp")
        .map("timestamp in ISO format") { timestampStr ->
            timestampStr?.let {
                try {
                    LocalDateTime.parse(it, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
                } catch (e: DateTimeParseException) {
                    // Try alternative formats
                    try {
                        LocalDateTime.parse(it, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
                    } catch (e2: DateTimeParseException) { null }
                }
            }
        }

    val scheduledTime by option("--time")
        .map("time in HH:MM format") { timeStr ->
            timeStr?.let {
                try {
                    LocalTime.parse(it, DateTimeFormatter.ofPattern("HH:mm"))
                } catch (e: DateTimeParseException) { null }
                }
            }
        }
}

Size and Duration Parsing

import java.time.Duration
import java.time.format.DateTimeParseException

class ResourceApp : Arguments() {
    val maxSize by option("--max-size")
        .map("size with unit (e.g., 100MB, 2GB)") { sizeStr ->
            sizeStr?.let { parseSize(it) }
        }

    val timeout by option("--timeout")
        .map("duration (e.g., 30s, 5m, 2h)") { durationStr ->
            durationStr?.let { parseDuration(it) }
        }

    val memoryLimit by option("--memory")
        .map("memory size (e.g., 512M, 2G)") { memStr ->
            memStr?.let { parseMemorySize(it) }
        }
}

fun parseSize(sizeStr: String): Long? {
    val regex = """(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?""".toRegex(RegexOption.IGNORE_CASE)
    val match = regex.matchEntire(sizeStr.trim()) ?: return null

    val number = match.groupValues[1].toDoubleOrNull() ?: return null
    val unit = match.groupValues[2].uppercase().ifEmpty { "B" }

    val multiplier = when (unit) {
        "B" -> 1L
        "KB" -> 1024L
        "MB" -> 1024L * 1024L
        "GB" -> 1024L * 1024L * 1024L
        "TB" -> 1024L * 1024L * 1024L * 1024L
        else -> return null
    }

    return (number * multiplier).toLong()
}

fun parseDuration(durationStr: String): Duration? {
    return try {
        // Try ISO 8601 duration format first
        Duration.parse(durationStr)
    } catch (e: DateTimeParseException) {
        // Try simple formats
        val regex = """(\d+)\s*([smhd])""".toRegex(RegexOption.IGNORE_CASE)
        val match = regex.matchEntire(durationStr.trim()) ?: return null

        val amount = match.groupValues[1].toLongOrNull() ?: return null
        val unit = match.groupValues[2].lowercase()

        when (unit) {
            "s" -> Duration.ofSeconds(amount)
            "m" -> Duration.ofMinutes(amount)
            "h" -> Duration.ofHours(amount)
            "d" -> Duration.ofDays(amount)
            else -> null
        }
    }
}

fun parseMemorySize(memStr: String): Long? {
    val regex = """(\d+(?:\.\d+)?)\s*([KMGT])?B?""".toRegex(RegexOption.IGNORE_CASE)
    val match = regex.matchEntire(memStr.trim()) ?: return null

    val number = match.groupValues[1].toDoubleOrNull() ?: return null
    val unit = match.groupValues[2].uppercase()

    val multiplier = when (unit) {
        "", "B" -> 1L
        "K" -> 1024L
        "M" -> 1024L * 1024L
        "G" -> 1024L * 1024L * 1024L
        "T" -> 1024L * 1024L * 1024L * 1024L
        else -> return null
    }

    return (number * multiplier).toLong()
}

Network Address Parsing

import java.net.InetAddress
import java.net.InetSocketAddress

class NetworkApp : Arguments() {
    val bindAddress by option("--bind")
        .map("IP address") { addressStr ->
            addressStr?.let {
                try {
                    InetAddress.getByName(it)
                } catch (e: Exception) { null }
            }
        }

    val serverEndpoint by option("--server")
        .map("host:port address") { endpointStr ->
            endpointStr?.let { parseHostPort(it) }
        }

    val proxies by option("--proxy").list()
        .map("proxy addresses") { proxyList ->
            proxyList?.mapNotNull { parseHostPort(it) }
        }
}

fun parseHostPort(hostPort: String): InetSocketAddress? {
    val parts = hostPort.split(":")
    if (parts.size != 2) return null

    val host = parts[0].trim()
    val port = parts[1].trim().toIntOrNull()

    return if (port != null && port in 1..65535) {
        try {
            InetSocketAddress(host, port)
        } catch (e: Exception) { null }
        } else null
}

Configuration Objects

Transform complex configuration strings into structured objects:

data class DatabaseConfig(
    val host: String,
    val port: Int,
    val database: String,
    val username: String? = null,
    val password: String? = null,
    val ssl: Boolean = false
)

data class RedisConfig(
    val host: String,
    val port: Int = 6379,
    val database: Int = 0,
    val password: String? = null
)

class DatabaseApp : Arguments() {
    val database by option("--database")
        .map("database URL") { url ->
            url?.let { parseDatabaseUrl(it) }
        }

    val redis by option("--redis")
        .map("Redis connection string") { redisStr ->
            redisStr?.let { parseRedisConfig(it) }
        }

    val servers by option("--server").list()
        .map("server configurations") { serverList ->
            serverList?.mapNotNull { parseServerConfig(it) }
        }
}

fun parseDatabaseUrl(url: String): DatabaseConfig? {
    // Parse URLs like: postgresql://user:pass@host:5432/dbname?ssl=true
    val regex = """(\w+)://(?:([^:]+)(?::([^@]+))?@)?([^:/]+)(?::(\d+))?/([^?]+)(?:\?(.+))?""".toRegex()
    val match = regex.matchEntire(url) ?: return null

    val scheme = match.groupValues[1]
    val username = match.groupValues[2].takeIf { it.isNotEmpty() }
    val password = match.groupValues[3].takeIf { it.isNotEmpty() }
    val host = match.groupValues[4]
    val port = match.groupValues[5].toIntOrNull() ?: when (scheme) {
        "postgresql" -> 5432
        "mysql" -> 3306
        else -> return null
    }
    val database = match.groupValues[6]
    val queryString = match.groupValues[7]

    val ssl = queryString?.contains("ssl=true") == true

    return DatabaseConfig(host, port, database, username, password, ssl)
}

fun parseRedisConfig(redisStr: String): RedisConfig? {
    // Parse formats like: localhost:6379/0, redis://password@host:port/db
    return when {
        redisStr.startsWith("redis://") -> {
            val regex = """redis://(?:([^@]+)@)?([^:/]+)(?::(\d+))?(?:/(\d+))?""".toRegex()
            val match = regex.matchEntire(redisStr) ?: return null

            val password = match.groupValues[1].takeIf { it.isNotEmpty() }
            val host = match.groupValues[2]
            val port = match.groupValues[3].toIntOrNull() ?: 6379
            val database = match.groupValues[4].toIntOrNull() ?: 0

            RedisConfig(host, port, database, password)
        }
        else -> {
            // Simple host:port/db format
            val parts = redisStr.split("/")
            val hostPort = parts[0]
            val database = parts.getOrNull(1)?.toIntOrNull() ?: 0

            val hostPortParts = hostPort.split(":")
            val host = hostPortParts[0]
            val port = hostPortParts.getOrNull(1)?.toIntOrNull() ?: 6379

            RedisConfig(host, port, database)
        }
    }
}

data class ServerConfig(val name: String, val host: String, val port: Int, val weight: Int = 1)

fun parseServerConfig(serverStr: String): ServerConfig? {
    // Parse formats like: name=host:port:weight or host:port
    val parts = serverStr.split("=")
    return when (parts.size) {
        1 -> {
            // Simple host:port format
            val hostPortWeight = parts[0].split(":")
            if (hostPortWeight.size >= 2) {
                val host = hostPortWeight[0]
                val port = hostPortWeight[1].toIntOrNull() ?: return null
                val weight = hostPortWeight.getOrNull(2)?.toIntOrNull() ?: 1
                ServerConfig(host, host, port, weight)
            } else null
        }
        2 -> {
            // name=host:port:weight format
            val name = parts[0]
            val hostPortWeight = parts[1].split(":")
            if (hostPortWeight.size >= 2) {
                val host = hostPortWeight[0]
                val port = hostPortWeight[1].toIntOrNull() ?: return null
                val weight = hostPortWeight.getOrNull(2)?.toIntOrNull() ?: 1
                ServerConfig(name, host, port, weight)
            } else null
        }
        else -> null
    }
}

Collection Transformations

Transform collections while preserving the collection type:

class CollectionApp : Arguments() {
    // Transform list elements
    val configFiles by option("--config").list()
        .map("readable config files") { paths ->
            paths?.mapNotNull { path ->
                File(path).takeIf { it.exists() && it.canRead() }
            }
        }

    // Transform and validate ports
    val serverPorts by option("--port").list()
        .map("valid port numbers") { ports ->
            ports?.mapNotNull { portStr ->
                portStr.toIntOrNull()?.takeIf { it in 1..65535 }
            }
        }

    // Transform set elements
    val allowedHosts by option("--allow-host").set()
        .map("valid hostnames") { hosts ->
            hosts?.mapNotNull { host ->
                if (isValidHostname(host)) host.lowercase() else null
            }?.toSet()
        }
}

fun isValidHostname(hostname: String): Boolean {
    if (hostname.isEmpty() || hostname.length > 253) return false

    val labels = hostname.split(".")
    return labels.all { label ->
        label.isNotEmpty() &&
        label.length <= 63 &&
        label.all { it.isLetterOrDigit() || it == '-' } &&
        !label.startsWith("-") &&
        !label.endsWith("-")
    }
}

Chaining Transformations

You can chain transformations with validation:

class ChainedApp : Arguments() {
    val configFile by option("--config")
        .map("existing file") { path ->
            path?.let { File(it).takeIf { f -> f.exists() } }
        }
        .validate("Config file must be readable") { it?.canRead() == true }
        .map("JSON config file") { file ->
            file?.let {
                if (it.extension == "json") it else null
            }
        }

    val apiEndpoint by option("--api-endpoint")
        .map("valid URL") { urlStr ->
            urlStr?.let {
                try { URL(it) } catch (e: Exception) { null }
            }
        }
        .validate("Must use HTTPS") { it?.protocol == "https" }
        .map("API endpoint") { url ->
            url?.let {
                // Ensure it ends with /api
                if (it.path.endsWith("/api")) it
                else URL("${it.protocol}://${it.host}:${it.port}${it.path}/api")
            }
        }
}

Non-Nullable Transformations

For non-nullable options, transformations work with NonNullableOptionBuilder:

class NonNullApp : Arguments() {
    // Transform required option
    val configFile by option("--config").required()
        .map("config file") { path -> File(path) }

    // Transform option with default
    val serverPort by option("--port").default("8080")
        .map("port number") { portStr -> portStr.toInt() }

    // Chain transformation after default
    val logLevel by option("--log-level").enum<LogLevel>().default(LogLevel.INFO)
        .map("log configuration") { level ->
            LogConfig(level, level == LogLevel.DEBUG)
        }
}

enum class LogLevel { DEBUG, INFO, WARN, ERROR }
data class LogConfig(val level: LogLevel, val verbose: Boolean)

Complete Examples

Configuration File Parser

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import java.util.Properties

class ConfigApp : Arguments() {
    val jsonConfig by option("--json-config")
        .map("JSON configuration") { jsonStr ->
            jsonStr?.let {
                try {
                    Json.parseToJsonElement(it) as? JsonObject
                } catch (e: Exception) { null }
                }
            }
        }

    val propertiesFile by option("--properties")
        .map("properties file") { path ->
            path?.let {
                val file = File(it)
                if (file.exists() && file.canRead()) {
                    val props = Properties()
                    file.inputStream().use { props.load(it) }
                    props
                } else null
            }
        }

    val keyValuePairs by option("--set").list()
        .map("key=value pairs") { pairs ->
            pairs?.mapNotNull { pair ->
                val parts = pair.split("=", limit = 2)
                if (parts.size == 2) parts[0] to parts[1] else null
            }?.toMap()
        }
}

Resource Specification Parser

data class ResourceSpec(
    val cpu: String,
    val memory: Long,
    val storage: Long? = null
)

data class ServiceSpec(
    val name: String,
    val image: String,
    val tag: String = "latest",
    val resources: ResourceSpec
)

class KubernetesApp : Arguments() {
    val resourceLimits by option("--resources")
        .map("resource specification (cpu=1,memory=512M,storage=10G)") { resourceStr ->
            resourceStr?.let { parseResourceSpec(it) }
        }

    val services by option("--service").list()
        .map("service specifications") { serviceList ->
            serviceList?.mapNotNull { parseServiceSpec(it) }
        }

    val nodeSelector by option("--node-selector")
        .map("node selector labels") { selectorStr ->
            selectorStr?.let { parseLabels(it) }
        }
}

fun parseResourceSpec(resourceStr: String): ResourceSpec? {
    val parts = resourceStr.split(",").associate { part ->
        val keyValue = part.split("=", limit = 2)
        if (keyValue.size == 2) keyValue[0].trim() to keyValue[1].trim() else return null
    }

    val cpu = parts["cpu"] ?: return null
    val memory = parts["memory"]?.let { parseMemorySize(it) } ?: return null
    val storage = parts["storage"]?.let { parseSize(it) }

    return ResourceSpec(cpu, memory, storage)
}

fun parseServiceSpec(serviceStr: String): ServiceSpec? {
    // Format: name:image:tag,cpu=1,memory=512M
    val mainParts = serviceStr.split(",", limit = 2)
    if (mainParts.isEmpty()) return null

    val imageParts = mainParts[0].split(":")
    if (imageParts.size < 2) return null

    val name = imageParts[0]
    val image = imageParts[1]
    val tag = imageParts.getOrNull(2) ?: "latest"

    val resourceStr = mainParts.getOrNull(1) ?: return null
    val resources = parseResourceSpec(resourceStr) ?: return null

    return ServiceSpec(name, image, tag, resources)
}

fun parseLabels(labelStr: String): Map<String, String>? {
    return labelStr.split(",").associate { part ->
        val keyValue = part.split("=", limit = 2)
        if (keyValue.size == 2) {
            keyValue[0].trim() to keyValue[1].trim()
        } else return null
    }
}

Best Practices

1. Provide Clear Transformation Descriptions

// Good: Clear, specific descriptions
val timeout by option("--timeout")
    .map("duration in seconds (e.g., 30, 60, 120)") { it?.toIntOrNull() }

val endpoint by option("--endpoint")
    .map("valid HTTPS URL") { url ->
        url?.let { try { URL(it) } catch (e: Exception) { null } }
    }

// Avoid: Vague or missing descriptions
val data by option("--data").map { it?.toIntOrNull() }

2. Handle All Edge Cases

// Good: Comprehensive error handling
val percentage by option("--load")
    .map("percentage (0-100)") { percentStr ->
        when {
            percentStr.isNullOrBlank() -> null
            percentStr.endsWith("%") -> {
                percentStr.dropLast(1).toDoubleOrNull()?.takeIf { it in 0.0..100.0 }
            }
            else -> percentStr.toDoubleOrNull()?.takeIf { it in 0.0..100.0 }
        }
    }

// Avoid: Minimal error handling
val percentage by option("--load").map { it?.toDouble() }

3. Keep Transformations Pure

// Good: Pure transformation functions
val configFile by option("--config")
    .map("readable config file") { path ->
        path?.let { File(it).takeIf { f -> f.exists() && f.canRead() } }
    }

// Avoid: Side effects in transformations
val configFile by option("--config")
    .map("config file") { path ->
        path?.let {
            println("Loading config from $it")  // Don't do this
            File(it)
        }
    }

4. Validate After Transformation

// Good: Transform then validate
val serverConfig by option("--server")
    .map("server configuration") { parseServerConfig(it) }
    .validate("Server must be reachable") { config ->
        // Validate the transformed object
        config?.let { isServerReachable(it.host, it.port) } != false
    }

// Consider: Some validation might be better in the transformation
val port by option("--port")
    .map("valid port (1-65535)") { portStr ->
        portStr?.toIntOrNull()?.takeIf { it in 1..65535 }
    }

Custom transformations enable rich, type-safe CLI interfaces that work with your application's domain objects while providing clear error messages for invalid inputs.