Ever found yourself needing to create a multitude of instances of a data class with slightly different parameters? Perhaps for testing, generating sample data, or populating a UI with various states. Manually creating these can be tedious and error-prone. What if we could automate this? This was the spark that led me to explore Kotlin Symbol Processing (KSP) and KotlinPoet to create Kombinator, an annotation processor that automatically generates all possible combinations of a data class’s constructor parameters. In this article, I’ll walk you through the process, the design decisions, and some of the interesting challenges encountered while building Kombinator. The Goal: What is Kombinator? At its core, Kombinator aims to simplify the creation of multiple instances of a Kotlin data class. You annotate a data class (or its parameters) with Kombine and provide the possible values for each parameter. Kombinator then generates an object containing properties for every unique combination of these parameters, plus a handy getAllCombinations() function to retrieve them all as a list. Kombine getAllCombinations() For example, imagine a UserSettings data class: UserSettings https://gist.github.com/sarimmehdi/d30182d8e755464ca6dbd1a06977e123?embedable=true#file-usersettings-kt https://gist.github.com/sarimmehdi/d30182d8e755464ca6dbd1a06977e123?embedable=true#file-usersettings-kt With Kombinator, you could annotate it like this: https://gist.github.com/sarimmehdi/fcbf995245a8463792131045369d210b?embedable=true#file-usersettingskombine-kt https://gist.github.com/sarimmehdi/fcbf995245a8463792131045369d210b?embedable=true#file-usersettingskombine-kt Kombinator would then generate an object, say UserSettingsCombinations, looking something like this (simplified): UserSettingsCombinations https://gist.github.com/sarimmehdi/1f63835ac9a2d2c1bdfb91d8a7ac3f12?embedable=true#file-usersettingscombinations-kt https://gist.github.com/sarimmehdi/1f63835ac9a2d2c1bdfb91d8a7ac3f12?embedable=true#file-usersettingscombinations-kt The Tools: KSP and KotlinPoet Kotlin Symbol Processing (KSP): KSP is an API developed by Google that allows you to write lightweight compiler plugins for Kotlin. It processes Kotlin code at compile time, providing access to the structure of your code (like classes, functions, parameters, and annotations) without needing to delve into the complexities of the Kotlin compiler internals. This makes it more efficient and stable than the older KAPT (Kotlin Annotation Processing Tool). KotlinPoet: From Square, KotlinPoet is a fantastic library for generating .kt source files. It provides a fluid API to construct Kotlin code programmatically, handling imports, formatting, and other details beautifully. Kotlin Symbol Processing (KSP): KSP is an API developed by Google that allows you to write lightweight compiler plugins for Kotlin. It processes Kotlin code at compile time, providing access to the structure of your code (like classes, functions, parameters, and annotations) without needing to delve into the complexities of the Kotlin compiler internals. This makes it more efficient and stable than the older KAPT (Kotlin Annotation Processing Tool). Kotlin Symbol Processing (KSP) KotlinPoet: From Square, KotlinPoet is a fantastic library for generating .kt source files. It provides a fluid API to construct Kotlin code programmatically, handling imports, formatting, and other details beautifully. KotlinPoet The Journey: Building Kombinator Let’s break down how Kombinator works and the key components involved. The Kombine Annotation The Kombine Annotation The Kombine Annotation This is the entry point. It’s a SOURCE retention annotation, meaning it’s only present during compilation and doesn’t make it into the final bytecode. It targets classes and value parameters. https://gist.github.com/sarimmehdi/ecb4e858ea9761ff1d2cb54be9afb3b6?embedable=true#file-kombine-kt https://gist.github.com/sarimmehdi/ecb4e858ea9761ff1d2cb54be9afb3b6?embedable=true#file-kombine-kt A crucial design choice here was to have separate array parameters for each supported data type (allPossibleStringParams, allPossibleIntParams, etc.). This makes it type-safe at the annotation usage site and simplifies parsing within the processor. Initially, I considered a more generic approach, but this explicitness proved more robust. allPossibleStringParams allPossibleIntParams 2. The ProcessorProvider and Processor The ProcessorProvider and Processor KSP works by discovering SymbolProcessorProvider implementations. Our ProcessorProvider is straightforward: SymbolProcessorProvider ProcessorProvider https://gist.github.com/sarimmehdi/22cd3e8046ae3e5db32eff37dfab1820?embedable=true#file-processorprovider-kt https://gist.github.com/sarimmehdi/22cd3e8046ae3e5db32eff37dfab1820?embedable=true#file-processorprovider-kt It simply creates an instance of our main Processor. The Processor is where the magic happens: https://gist.github.com/sarimmehdi/e0a912b815513d4777d038753cc59687?embedable=true#file-processor-kt https://gist.github.com/sarimmehdi/e0a912b815513d4777d038753cc59687?embedable=true#file-processor-kt The process method is the entry point for KSP. It: Gets the qualified name of our Kombine annotation. Uses the Resolver to find all symbols (classes, parameters, etc.) annotated with Kombine. Filters for valid KSClassDeclaration instances. Invokes a DataClassVisitor for each annotated class. Gets the qualified name of our Kombine annotation. Kombine Uses the Resolver to find all symbols (classes, parameters, etc.) annotated with Kombine. Kombine Filters for valid KSClassDeclaration instances. KSClassDeclaration Invokes a DataClassVisitor for each annotated class. DataClassVisitor 3. The DataClassVisitor: Inspecting and Gathering Information The DataClassVisitor Inspecting and Gathering Information The DataClassVisitor (implemented as an inner class for access to logger and codeGenerator) uses the KSP Visitor pattern to traverse the AST of the annotated class. logger codeGenerator https://gist.github.com/sarimmehdi/a81c4c295f1bee62bba13590ca73cb53?embedable=true#file-visitclassdeclaration-kt https://gist.github.com/sarimmehdi/a81c4c295f1bee62bba13590ca73cb53?embedable=true#file-visitclassdeclaration-kt A helper data class, ConstructorParameterInfo, was essential for organizing the extracted details about each constructor parameter: ConstructorParameterInfo https://gist.github.com/sarimmehdi/abd4241df9219d7f0ca61476c2e2d758?embedable=true#file-constructorparameterinfo-kt https://gist.github.com/sarimmehdi/abd4241df9219d7f0ca61476c2e2d758?embedable=true#file-constructorparameterinfo-kt 4. Reading Annotation Values and Building Combinable Groups Reading Annotation Values and Building Combinable Groups This is where the logic gets interesting. We need to determine which values to combine for each parameter. Booleans: For Boolean parameters without default values, we automatically assume [false, true] as the combinable values. Enums: For Enum parameters without default values, we extract all enum entries. Other Types (from Kombine): This is handled by the readParameter function. A key design principle here is that if a Kombine annotation is present directly on a constructor parameter, its specified values will always take precedence over any values defined in a class-level Kombine annotation for that same parameter type. This allows for fine-grained control. Handling of Default Values: It’s important to note that Kombinator is designed to generate combinations based on explicitly provided values or inherent options (like booleans and enums). If a parameter has a default value in the data class constructor and is not explicitly targeted by a Kombine annotation (either at the parameter or class level with values for its type), Kombinator will not automatically include it in the combination generation. The expectation is that you explicitly define the range of values you want to combine for parameters you’re interested in varying. If a parameter with a default value is targeted by Kombine, then the values from the annotation will be used, overriding the default for the generated combinations. Booleans: For Boolean parameters without default values, we automatically assume [false, true] as the combinable values. Booleans Enums: For Enum parameters without default values, we extract all enum entries. Enums Other Types (from Kombine): This is handled by the readParameter function. A key design principle here is that if a Kombine annotation is present directly on a constructor parameter, its specified values will always take precedence over any values defined in a class-level Kombine annotation for that same parameter type. This allows for fine-grained control. Other Types (from Kombine) readParameter Kombine Kombine Handling of Default Values: It’s important to note that Kombinator is designed to generate combinations based on explicitly provided values or inherent options (like booleans and enums). If a parameter has a default value in the data class constructor and is not explicitly targeted by a Kombine annotation (either at the parameter or class level with values for its type), Kombinator will not automatically include it in the combination generation. The expectation is that you explicitly define the range of values you want to combine for parameters you’re interested in varying. If a parameter with a default value is targeted by Kombine, then the values from the annotation will be used, overriding the default for the generated combinations. Handling of Default Values Kombine Kombine https://gist.github.com/sarimmehdi/c8ebdc9531298dfa8a3f078c1b0169f1?embedable=true#file-dataclassvisitor-kt https://gist.github.com/sarimmehdi/c8ebdc9531298dfa8a3f078c1b0169f1?embedable=true#file-dataclassvisitor-kt The readParameter function (from ReadParameter.kt) is crucial. It iterates through constructor parameters that are not booleans, enums, and crucially, only considers those that do not have default values unless they are explicitly annotated with Kombine to provide values. This is because the goal is to combine based on provided sets of possibilities, not just use a single default. readParameter ReadParameter.kt Kombine https://gist.github.com/sarimmehdi/ac8a5f0c4015cfb369d85e326825253b?embedable=true#file-readparameter-kt https://gist.github.com/sarimmehdi/ac8a5f0c4015cfb369d85e326825253b?embedable=true#file-readparameter-kt And the readAnnotationArrayArgument utility helps extract values from the Kombine annotation’s array arguments: readAnnotationArrayArgument Kombine https://gist.github.com/sarimmehdi/d5efb006624e85fdf18e8a617487527b?embedable=true#file-readannotationarrayargument-kt https://gist.github.com/sarimmehdi/d5efb006624e85fdf18e8a617487527b?embedable=true#file-readannotationarrayargument-kt Challenge — Unsigned Types: A notable challenge arose with unsigned types (UByte, UShort, etc.). When KSP reads values from an annotation like val allPossibleUByteParams: UByteArray = [1u, 2u], the argument.value for allPossibleUByteParams surprisingly yields a List<Byte> (or List<Short> for UShortArray, etc.) rather than List<UByte>. This required careful casting and conversion logic within the caster lambda passed to readAnnotationArrayArgument. Challenge — Unsigned Types 5. Generating the Code with KotlinPoet Generating the Code with KotlinPoet Once we have the combinableParameterGroups (a list of pairs, where each pair is ConstructorParameterInfo and its List<Any> of possible values), we can start generating code.This is handled by the writeProperties and generateCode utility functions. combinableParameterGroups ConstructorParameterInfo List<Any> writeProperties generateCode writeProperties: This function iterates through all possible combinations and creates a KotlinPoet PropertySpec for each. writeProperties PropertySpec https://gist.github.com/sarimmehdi/9289de7eff82be8b3fa41a837bccc7c5?embedable=true#file-writeproperties-kt https://gist.github.com/sarimmehdi/9289de7eff82be8b3fa41a837bccc7c5?embedable=true#file-writeproperties-kt generateInstanceProperty: This creates the actual PropertySpec for one instance. generateInstanceProperty https://gist.github.com/sarimmehdi/4079f4ecaacc82178d93fa4989609b96?embedable=true#file-generateinstanceproperty-kt https://gist.github.com/sarimmehdi/4079f4ecaacc82178d93fa4989609b96?embedable=true#file-generateinstanceproperty-kt Challenge — KotlinPoet Literals: KotlinPoet is powerful, but you need to use the correct format specifiers for literals. %S for strings (adds quotes), %L for general literals, but floats need f suffix (so %Lf), chars need single quotes (‘%L’), and unsigned types need a u suffix (achieved with %Lu after casting the KSP-provided value to its underlying signed type and then to Long for KotlinPoet’s %Lu to work as expected for all unsigned sizes). This required careful construction of CodeBlocks. Challenge — KotlinPoet Literals CodeBlocks generateCode: Finally, this function assembles the generated object, adds the getAllCombinations() function, and writes the file. generateCode getAllCombinations() https://gist.github.com/sarimmehdi/6ccd8026b4fee6017a5243f13d1be4cc?embedable=true#file-generatecode-kt https://gist.github.com/sarimmehdi/6ccd8026b4fee6017a5243f13d1be4cc?embedable=true#file-generatecode-kt Dependency Management with KSP: The Dependencies object is important. Dependencies(aggregating = false, file) tells KSP that the generated file depends on the source file (file) that contains the annotated class. If the source file changes, KSP knows it needs to reprocess and potentially regenerate this output. aggregating = false means this processor doesn’t aggregate information from multiple source files to produce a single output. Dependency Management with KSP Dependencies(aggregating = false, file) 6. Logging and Error Handling Logging and Error Handling Throughout the process, using KSPLogger (logger.error(), logger.warn(), logger.info()) is crucial for providing feedback to the user about what the processor is doing, any misconfigurations, or errors encountered. Clear error messages are key to a good developer experience. logger.error() logger.warn() logger.info() Challenges and Learnings Understanding KSP Types vs. KotlinPoet Types: KSP provides its own representations of types (KSType, KSDeclaration). These often need to be converted to KotlinPoet’s TypeName using extensions like toTypeName() from com.squareup.kotlinpoet.ksp.toTypeName. Reading Annotation Arguments: Accessing annotation arguments (annotation.arguments) and their values (argument.value) is straightforward, but the type of argument.value can sometimes be a bit surprising (e.g., List<Any> for arrays, the unsigned types issue mentioned earlier). Robust casting and type checking are necessary. Iterative Combination Logic: The algorithm in writeProperties to iterate through all combinations (incrementing indices much like counting in different bases) is a classic combinatorial problem. KotlinPoet Formatting: Mastering CodeBlocks and format specifiers (%N for names, %T for types, %L for literals, %S for strings) is key to generating clean, correct Kotlin code. The indent (⇥) and unindent (⇤) characters are very helpful for readability. Understanding KSP Types vs. KotlinPoet Types: KSP provides its own representations of types (KSType, KSDeclaration). These often need to be converted to KotlinPoet’s TypeName using extensions like toTypeName() from com.squareup.kotlinpoet.ksp.toTypeName. Understanding KSP Types vs. KotlinPoet Types KSType KSDeclaration toTypeName() Reading Annotation Arguments: Accessing annotation arguments (annotation.arguments) and their values (argument.value) is straightforward, but the type of argument.value can sometimes be a bit surprising (e.g., List<Any> for arrays, the unsigned types issue mentioned earlier). Robust casting and type checking are necessary. Reading Annotation Arguments annotation.arguments argument.value Iterative Combination Logic: The algorithm in writeProperties to iterate through all combinations (incrementing indices much like counting in different bases) is a classic combinatorial problem. Iterative Combination Logic writeProperties KotlinPoet Formatting: Mastering CodeBlocks and format specifiers (%N for names, %T for types, %L for literals, %S for strings) is key to generating clean, correct Kotlin code. The indent (⇥) and unindent (⇤) characters are very helpful for readability. KotlinPoet Formatting CodeBlocks Conclusion Building Kombinator was a rewarding dive into the world of KSP and KotlinPoet. It showcased how these tools can be used to significantly reduce boilerplate and automate repetitive coding tasks. While there were certainly hurdles, particularly around type handling and the nuances of KSP’s API, the end result is a utility that can genuinely save time and effort. If you’re looking to automate code generation in your Kotlin projects, I highly recommend exploring KSP. The learning curve is manageable, and the power it offers is substantial. Happy Koding (and Kombining)! Github: https://github.com/sarimmehdi/Kombinator https://github.com/sarimmehdi/Kombinator