MakisDSL

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:

💡 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