How to Handle Null Values and Specify Separators for Collection Properties in OWNER
OWNER treats missing configuration keys as null for reference types (or throws NullPointerException for primitives) and tokenizes collection values on commas by default, but you can customize both behaviors using @DefaultValue, @Separator, and @TokenizerClass annotations.
The OWNER library (matteobaccan/owner) maps Java properties files to typed interfaces, but handling missing values and parsing list-based configurations requires understanding its specific rules for null handling and collection tokenization. This guide explains exactly how the framework resolves undefined properties and how to control delimiter logic for array or collection return types.
Understanding Null Handling in OWNER
OWNER follows predictable rules when a property is missing, empty, or explicitly set to null. These rules differ based on whether the return type is a primitive, wrapper, or collection.
Undefined Properties and Default Behavior
When a property is not defined in any source and the interface method lacks a @DefaultValue annotation, OWNER returns null for reference types. However, for primitive return types (e.g., int, boolean), the framework throws a NullPointerException because primitives cannot hold null values.
According to the source code in PropertiesInvocationHandler.java (lines 77-87), the resolveProperty method returns null when no value is found, which then triggers the exception during auto-unboxing. To prevent this, declare a sensible default:
public interface ServerConfig extends Config {
@DefaultValue("8080")
int port(); // Returns 8080 instead of throwing NPE
@DefaultValue("localhost")
String host(); // Returns "localhost" instead of null
}
Empty Values vs. Null Literals
An empty string ("") behaves differently depending on the return type. For collections and arrays, OWNER interprets an empty value as an empty collection (zero elements), not null. For scalar types, the empty string passes through the converter chain, typically resulting in null or a conversion error.
The literal string "null" is treated as the text value "null", not the Java null reference. If you need to explicitly remove a property at runtime, use ConfigFactory.setProperty(key, null) or ConfigFactory.clearProperty(key), which removes the entry from the underlying Properties object entirely.
Customizing Collection Separators
By default, OWNER tokenizes collection properties using a comma (,) delimiter. You can override this using two mechanisms: the @Separator annotation or a custom tokenizer class.
Using the @Separator Annotation
The @Separator annotation, defined in Config.java (lines 312-317), specifies a delimiter string passed directly to String.split. You can apply it at the method level for specific properties or at the interface level as a default for all methods in that config.
The TokenizerResolver (lines 59-71) processes this annotation by instantiating a SplitAndTrimTokenizer, which splits the value and trims surrounding whitespace:
@Separator(";")
@DefaultValue("red; green; blue")
List<String> colors();
// Class-level default with method override
@Separator(",")
interface CsvConfig extends Config {
List<Integer> numbers(); // Uses comma
@Separator("|")
List<String> pipes(); // Uses pipe, overriding class default
}
Implementing Custom Tokenizers
For complex parsing requirements (regex patterns, multi-character delimiters, or conditional logic), implement the Tokenizer interface and reference your class with @TokenizerClass:
public class DashTokenizer implements Tokenizer {
@Override
public String[] tokens(String values) {
return Arrays.stream(values.split("-", -1))
.map(String::trim)
.toArray(String[]::new);
}
}
// Usage
@TokenizerClass(DashTokenizer.class)
@DefaultValue("one-two-three")
String[] dashSeparated();
Tokenizer Resolution Rules
The TokenizerResolver enforces a strict mutual exclusivity rule: you cannot use @Separator and @TokenizerClass on the same level. Attempting this throws an UnsupportedOperationException (see TokenizerResolver.java, lines 63-66). The resolver checks for @TokenizerClass first; if absent, it looks for @Separator; if neither exists, it defaults to the comma-based SplitAndTrimTokenizer.
The resolved tokenizer feeds into the COLLECTION converter in Converters.java (lines 66-76), which first converts tokens via the ARRAY converter (lines 40-63) and then builds the requested collection type (ArrayList, LinkedHashSet, etc.).
Practical Implementation Examples
This complete example demonstrates null safety, custom separators, and tokenizer implementation:
public interface ApplicationConfig extends Config {
// Returns empty list if property is "", returns null if missing
@Separator(";")
@DefaultValue("debug;info;warn")
List<String> logLevels();
// Uses custom tokenizer, never null due to default
@TokenizerClass(DashTokenizer.class)
@DefaultValue("dev-test-prod")
String[] environments();
// Primitive with default prevents NullPointerException
@DefaultValue("30")
int timeoutSeconds();
}
// Custom tokenizer implementation
public class DashTokenizer implements Tokenizer {
@Override
public String[] tokens(String input) {
if (input == null || input.isEmpty()) {
return new String[0];
}
return Arrays.stream(input.split("-"))
.map(String::trim)
.toArray(String[]::new);
}
}
// Runtime usage
ApplicationConfig cfg = ConfigFactory.create(ApplicationConfig.class);
List<String> levels = cfg.logLevels(); // [debug, info, warn]
Summary
- Missing properties return
nullfor objects but throwNullPointerExceptionfor primitives unless you specify@DefaultValue. - Empty strings yield empty collections for list/array types, not
null. - Default separator is a comma; override with
@Separatoror customTokenizerclasses. - Mutual exclusivity:
@Separatorand@TokenizerClasscannot coexist on the same method or interface. - Source locations: Logic resides in
PropertiesInvocationHandler.java(null handling),TokenizerResolver.java(separator resolution), andConverters.java(collection building).
Frequently Asked Questions
What happens if a collection property is missing in OWNER?
If the property is undefined and has no @DefaultValue, the method returns null exactly like scalar properties. If you need an empty collection instead, annotate the method with @DefaultValue("") or provide a specific default list.
Can I use regex as a separator in OWNER?
The built-in @Separator uses literal string delimiters passed to String.split, which interprets the argument as a regex. For complex regex patterns or custom parsing logic, implement a Tokenizer class and annotate the method with @TokenizerClass(YourTokenizer.class).
Why does OWNER throw NullPointerException for primitive return types?
When a property is missing, PropertiesInvocationHandler.resolveProperty (lines 77-87) returns null. Auto-unboxing this null into a primitive type (e.g., int, double) triggers a NullPointerException. Use wrapper types (Integer, Double) or add @DefaultValue to prevent this.
How do I make a collection property return null instead of an empty list?
By default, an empty property value ("") produces an empty collection, not null. To distinguish between "empty" and "missing," check for the empty string in a custom Tokenizer or return Optional<List<T>> from your config method and map empty lists to Optional.empty() in your application code.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →