Type Safety & Validation
MakisDSL leverages Scala 3's advanced type system to provide compile-time validation, ensuring infrastructure definitions are correct before deployment.
🔗 Overview
MakisDSL provides multiple layers of validation to catch configuration errors early:
- Compile-Time Validation - Errors caught during compilation, not runtime
- Type-Safe Builders - Phantom types track required vs. optional properties
- Required Property Enforcement - Cannot build resources without required configuration
- Environment Variable Validation - Missing environment variables cause compilation failures
- Helpful Error Messages - Clear guidance on how to fix configuration issues
💡 Why Compile-Time Validation?
Catching infrastructure errors at compile-time prevents costly deployment failures and reduces debugging time. If your MakisDSL code compiles, you can be confident your infrastructure is properly configured.
🔗 Compile-Time Validation
MakisDSL uses Scala 3's compile-time features to validate infrastructure definitions during compilation.
Phantom Types for Property Tracking
MakisDSL uses phantom types to track which properties have been set on resources:
// Internal validation types
sealed trait PropertyStatus
sealed trait Set extends PropertyStatus
sealed trait Unset extends PropertyStatus
// Specific property types
sealed trait RuntimeStatus extends PropertyStatus
sealed trait HandlerStatus extends PropertyStatus
sealed trait HashKeyStatus extends PropertyStatus
type RuntimeSet = Set
type RuntimeUnset = Unset
type HandlerSet = Set
type HandlerUnset = Unset
type HashKeySet = Set
type HashKeyUnset = Unset
Builder Type Evolution
As you configure resources, the builder's type changes to reflect what has been set:
// Initial builder: nothing set yet
val builder: ServerlessFunctionBuilder[RuntimeUnset, HandlerUnset] =
serverlessFunction("my-function")
// Cannot build yet - missing required properties
// After setting runtime: runtime is now Set
val builder: ServerlessFunctionBuilder[RuntimeSet, HandlerUnset] =
serverlessFunction("my-function")
.withRuntime("nodejs18.x")
// Still cannot build - handler still missing
// After setting handler: both are now Set
val builder: ServerlessFunctionBuilder[RuntimeSet, HandlerSet] =
serverlessFunction("my-function")
.withRuntime("nodejs18.x")
.withHandler("index.handler")
// Now we can build!
// Building with all required properties
val function: ServerlessFunction =
serverlessFunction("my-function")
.withRuntime("nodejs18.x")
.withHandler("index.handler")
.build // ✅ Compiles successfully
🔗 Type-Safe Builders
Builder classes use phantom types to track configuration state and prevent invalid builds.
Serverless Function Builder
The ServerlessFunctionBuilder tracks runtime and handler configuration:
case class ServerlessFunctionBuilder[R <: PropertyStatus, H <: PropertyStatus](
name: String,
properties: CloudConfig = Map.empty,
dependencies: List[CloudResource] = List.empty
):
// Setting runtime changes type from RuntimeUnset to RuntimeSet
def withRuntime(runtime: String): ServerlessFunctionBuilder[RuntimeSet, H] =
this.copy(properties = properties + ("Runtime" -> runtime))
.asInstanceOf[ServerlessFunctionBuilder[RuntimeSet, H]]
// Setting handler changes type from HandlerUnset to HandlerSet
def withHandler(handler: String): ServerlessFunctionBuilder[R, HandlerSet] =
this.copy(properties = properties + ("Handler" -> handler))
.asInstanceOf[ServerlessFunctionBuilder[R, HandlerSet]]
NoSQL Table Builder
The NoSqlTableBuilder tracks hash key configuration:
case class NoSqlTableBuilder[K <: PropertyStatus](
name: String,
properties: CloudConfig = Map.empty,
dependencies: List[CloudResource] = List.empty
):
// Setting hash key changes type from HashKeyUnset to HashKeySet
def withHashKey(keyName: String, keyType: String = "S"): NoSqlTableBuilder[HashKeySet] =
val keySchema = List(Map("AttributeName" -> keyName, "KeyType" -> "HASH"))
val attributeDefinitions = List(
Map("AttributeName" -> keyName, "AttributeType" -> keyType)
)
this.copy(properties =
properties +
("KeySchema" -> keySchema) +
("AttributeDefinitions" -> attributeDefinitions) +
("BillingMode" -> "PAY_PER_REQUEST")
).asInstanceOf[NoSqlTableBuilder[HashKeySet]]
🔗 Required Properties
MakisDSL enforces required properties at compile-time using Scala 3's @compileTimeOnly
annotation.
Build Method Restrictions
The .build
method is only available when all required properties are set:
// ✅ This compiles - all required properties set
val function = serverlessFunction("my-api")
.withRuntime("nodejs18.x") // Required
.withHandler("index.handler") // Required
.build // ✅ Available because both Runtime and Handler are set
// ❌ This fails to compile - missing required properties
val function = serverlessFunction("my-api")
.withRuntime("nodejs18.x") // Runtime set
// .withHandler("index.handler") // Handler missing!
.build // ❌ Compiler error: build method not available
Extension Method Build Validation
Extension methods provide the .build
method only for properly configured builders:
// Extension methods for type-safe building
extension (builder: ServerlessFunctionBuilder[RuntimeSet, HandlerSet])
(using cloudBuilder: CloudAppBuilder)
def build: ServerlessFunction =
val resource = ServerlessFunction(builder.name, builder.properties, builder.dependencies)
cloudBuilder.addResource(resource)
resource
extension (builder: NoSqlTableBuilder[HashKeySet])
(using cloudBuilder: CloudAppBuilder)
def build: NoSqlTable =
val resource = NoSqlTable(builder.name, builder.properties, builder.dependencies)
cloudBuilder.addResource(resource)
resource
🔗 Environment Validation
MakisDSL validates environment variables at compile-time using Scala 3 macros.
Required Environment Variables
Missing required environment variables cause compilation failures:
// ✅ This compiles if TEST_BUCKET_NAME is set
val storage = objectStorage(env("TEST_BUCKET_NAME"))
.withVersioning(true)
// Terminal: export TEST_BUCKET_NAME="my-prod-bucket"
// Result: Creates bucket named "my-prod-bucket"
// ❌ This fails to compile if MISSING_VAR is not set
val storage = objectStorage(env("MISSING_VAR"))
// Compiler error:
// "Environment variable 'MISSING_VAR' not found.
// Use env(key, default) for optional vars."
Environment Variables with Defaults
Use defaults for optional environment variables:
// ✅ Always compiles - uses default if environment variable not set
val function = serverlessFunction(s"${env("ENVIRONMENT", "dev")}-api-handler")
.withRuntime("nodejs18.x")
.withHandler("index.handler")
.build
// If ENVIRONMENT="prod": creates "prod-api-handler"
// If ENVIRONMENT not set: creates "dev-api-handler"
Compile-Time Environment Macro
The environment macro implementation validates at compile-time:
object EnvMacro:
inline def env(key: String): String = ${ envImpl('key) }
inline def env(key: String, default: String): String = ${ envWithDefaultImpl('key, 'default) }
private def envImpl(key: Expr[String])(using Quotes): Expr[String] =
import quotes.reflect.*
key.value match
case Some(keyStr) =>
val value = sys.env.get(keyStr).orElse(Option(System.getProperty(keyStr)))
value match
case Some(v) => Expr(v)
case None =>
report.errorAndAbort(
s"Environment variable '$keyStr' not found. Use env(key, default) for optional vars."
)
🔗 Runtime Suggestions
MakisDSL provides helpful suggestions for common configuration mistakes.
Runtime Validation
Invalid runtime strings trigger helpful suggestions:
object RuntimeSuggestions:
val validRuntimes = List(
"nodejs18.x", "nodejs16.x", "nodejs14.x",
"python3.9", "python3.8", "python3.7",
"java17", "java11", "java8",
"dotnet6", "dotnet5", "dotnetcore3.1",
"go1.x", "ruby2.7"
)
def suggest(invalid: String): String =
val closest = validRuntimes.minBy(valid =>
levenshteinDistance(invalid.toLowerCase, valid.toLowerCase)
)
s"Invalid runtime '$invalid'. Did you mean '$closest'? Valid options: ${validRuntimes.mkString(", ")}"
Example Runtime Suggestions
RuntimeSuggestions.suggest("nodejs17.x")
// Returns: "Invalid runtime 'nodejs17.x'. Did you mean 'nodejs18.x'?
// Valid options: nodejs18.x, nodejs16.x, nodejs14.x, ..."
RuntimeSuggestions.suggest("python4")
// Returns: "Invalid runtime 'python4'. Did you mean 'python3.9'?
// Valid options: nodejs18.x, nodejs16.x, python3.9, ..."
RuntimeSuggestions.suggest("java12")
// Returns: "Invalid runtime 'java12'. Did you mean 'java11'?
// Valid options: nodejs18.x, java17, java11, java8, ..."
🔗 Error Messages
MakisDSL provides clear, actionable error messages for common mistakes.
Missing Required Properties
Compile-time error messages guide you to fix configuration issues:
// Code that triggers error:
val function = serverlessFunction("my-api")
.withHandler("index.handler")
.build // Missing runtime!
// Compiler error:
// "Lambda function requires Runtime.
// Add .withRuntime("nodejs18.x") before .build()"
// Code that triggers error:
val function = serverlessFunction("my-api")
.withRuntime("nodejs18.x")
.build // Missing handler!
// Compiler error:
// "Lambda function requires Handler.
// Add .withHandler("index.handler") before .build()"
// Code that triggers error:
val table = noSqlTable("my-table")
.build // Missing hash key!
// Compiler error:
// "NoSQL table requires Hash Key.
// Add .withHashKey("id", "S") before .build()"
Error Implementation
Error messages are implemented using @compileTimeOnly
annotations:
extension [R <: PropertyStatus, H <: PropertyStatus](
builder: ServerlessFunctionBuilder[R, H]
)
@compileTimeOnly(
"Lambda function requires Runtime. Add .withRuntime(\"nodejs18.x\") before .build()"
)
def buildMissingRuntime(using evidence: R =:= RuntimeUnset): Nothing = ???
@compileTimeOnly(
"Lambda function requires Handler. Add .withHandler(\"index.handler\") before .build()"
)
def buildMissingHandler(using evidence: H =:= HandlerUnset): Nothing = ???
extension [K <: PropertyStatus](builder: NoSqlTableBuilder[K])
@compileTimeOnly(
"NoSQL table requires Hash Key. Add .withHashKey(\"id\", \"S\") before .build()"
)
def buildMissingHashKey(using evidence: K =:= HashKeyUnset): Nothing = ???
Testing Validation
You can test validation in your projects:
class ValidationTest extends munit.FunSuite {
test("compile-time validation enforces required properties") {
val myApp = cloudApp(provider = AWS) {
val bucket = objectStorage("my-data-bucket")
.withVersioning(true)
// MUST have runtime and handler before .build()
val function = serverlessFunction("my-api-handler")
.withRuntime("nodejs18.x") // Required
.withHandler("index.handler") // Required
.withCode(bucket.reference)
.dependsOn(bucket)
.build // ✅ Compiles because both properties are set
val table = noSqlTable("my-users-table")
.withHashKey("userId", "S") // Required
.dependsOn(function)
.build // ✅ Compiles because hash key is set
}
// If this compiles, validation is working correctly
assert(myApp.resources.size == 3)
}
}
🎯 Validation Benefits
- Early Error Detection - Find problems before deployment
- Better Developer Experience - Clear error messages and suggestions
- Infrastructure Confidence - If it compiles, it's configured correctly
- Reduced Debugging - Less time spent troubleshooting deployment issues