❗
|
Still in Early Development
This tool is still very early in development. Breaking changes are made all the time, documentation is sparse, insufficient code coverage, hard to use, etc. Feel free to use it in your own projects, but keep all that in mind. I’d love your feedback, too. |
TypedConfig is a JVM tool and library written in Kotlin to get you actual typesafety when adding configuration to your application or library.
What this means is that, for example, when reading the port number for your web application to listen on, you don’t need to parse it from a string, or call some kind of getInt("port")
-type method (which does all kinds of parsing and casts for you), but rather you can just call MyConfig.port
. This still does some parsing for you — it has to — but as a consumer of that configuration you get things like autocomplete and config documentation (if provided) for free.
You accomplish this by writing out a spec in a config.tc.toml
file which might look something like this:
[port] # (1)
type = "int" # (2)
default = 8080 # (3)
checks = ["validport"] # (4)
ℹ️
|
If the TOML syntax is unfamiliar to you, you can learn more about it at toml.io. |
This fragment does four things:
-
Defines a new key called
port
, which corresponds to a new property in the generated class. Keys are expected in lower camelcase, e.g.applicationPort
. -
It’s of type
int
(integer). Required field. -
It has a default value that will be used if a value isn’t found in any configuration sources. Optional field; if unset, configuration load will fail if it can’t be resolved.
-
It has one check — it must be a valid port. Optional; if unset, no checks are performed (other than typechecks).
This causes the following source code to be generated (edited for clarity):
class GeneratedConfig(source: Source) {
val port: Int by IntKey("port", source, 8080, listOf(ValidPortCheck))
companion object Factory {
fun default() = GeneratedConfig(TypedConfig.defaultSource)
}
}
Which you can then access in your own code:
val config = GeneratedConfig.default() // Initialize using default sources, i.e. EnvSource
val port: Int = config.port // ultimately reads System.getenv("PORT") in this case
- Provide typesafe access to configuration
-
When accessing configuration values, you know definitively what type it is, what the default value is, whether it’s required, and what other related configuration is available. Also, hand-written documentation may also be available (if provided by the author).
- Provide configuration from multiple sources
-
Look up your configuration from environment variables, static files, network services, or any combination thereof. Specify your configuration in a file but override it using an environment variable. Or specify your configuration in multiple files (say, one for each deployment stage and one for defaults), and override your defaults using deployment stage-specific config.
- Fail fast as early as possible
-
If configuration is missing or incorrect, how quickly can we detect it and fail? TypedConfig provides checks and validation for this.
- Good for both applications and libraries
-
Making TypedConfig accessible to applications is easy: they control the config spec, config values, and runtime. Things are a little trickier for libraries because while they have a runtime component, they don’t want to telegraph the internals of their configuration library to their consumers.
Firstly, to set your expectations correctly: what this tool does a little tricky, and some of that trickiness leaks into the usage. It takes your configuration, turns it into source code, and then your code can compile against it. That is, your code won’t be able to rely on the generated source code until after it’s generated! The Gradle plugin guarantees the config classes will be generated before they’re used, but if you’re using an IDE, you may need to perform a Gradle build first.
To apply the plugin itself, follow the directions on the Gradle Plugin page.
Next, create the following file in your project root (next to your settings.gradle file):
class = "com.example.GeneratedConfig"
[greeting]
type = "str"
description = "A friendly greeting."
default = "Hello!"
Now when you run ./gradlew generateTypedConfigs
(or any compile
-related task), a GeneratedConfig
file will be generated for you in build/generated-config
, which is automatically added to your main Gradle source sets. Now you can adapt it to your requirements.
See here for a sample.
To use the SNAPSHOT
version of TypedConfig instead of the officially released one, see SNAPSHOT Usage.
Once the configuration class has been generated, you just need to construct the generated class and query its properties like any other class.
If your generated config is called GeneratedConfig
, this looks like this:
val config = GeneratedConfig.default()
val port = config.port
Or if you want to specify a custom source for your configuration, like this:
val config = GeneratedConfig.default(EnvSource())
val port = config.port
If you’re using libraries that are using TypedConfig, and you want to change their configuration sources, you can write this:
TypedConfig.defaultSource = EnvSource()
This works if 1. you call it before the upstream library has constructed its configuration, and 2. that library is using the default()
factory method for its own configuration (or is directly referring to TypedConfig.defaultSource
).
Configuration sources provide the actual values at runtime. For example, one of the sources is EnvSource
, which looks
up configuration in environment variables. This may require translating the key — if you query EnvSource
using the
key port
, it’ll check the PORT
environment variable, for instance.
You can choose to provide these sources either to each config object as you construct them or globally, as a default (on [TypedConfig]).
There are a number of built-in sources that you can use to provide configuration.
EnvSource reads environment variables to populate configuration.
Keys are translated from lower camel case to screaming snake case when checking in the environment.
Config Key | Environment Variable |
---|---|
|
|
|
|
MapSource simply takes a Map<String, Any>
as a constructor argument that you provide when constructing the source. The map can be hardcoded or built any way you like.
By default, keys are passed through as is — the key applicationPort
is queried directly against the map as applicationPort
.
MultiSource is a higher-order source that takes a list of other sources as an input. When querying the MultiSource, it simply queries each source provided until one provides a non-null value.
If one constructs a MultiSource like this:
val source = MultiSource(source1, source2)
val config = GeneratedConfig(source)
val port = config.port
Then MultiSource will query source1
for the configuration, and if none is found, query source2
, and so on.
If this behavior isn’t to your needs, you can also implement your own Source.
CachedSource is another higher-order source that wraps another, presumably slow, source, by calling through to the delegated source and saving its results internally.
It also defines a .cached()
extension method on Sources for convenience.
Usage is like this:
val source: Source = MySlowSource().cached()
However, none of the built-in sources are slow enough to benefit from caching, so this is provided mainly for user-provided sources that perhaps pull configuration from the network.