Schematic

Schematic is a library that provides:

  • A layered serde-driven configuration system with support for merge strategies, validation rules, environment variables, and more!
  • A schema modeling system that can be used to generate TypeScript types, JSON schemas, and more!

Both of these features can be used independently or together.

cargo add schematic

Example references

The following projects are using Schematic and can be used as a reference:

  • moon - A build system for web based monorepos.
  • proto - A multi-language version manager with WASM plugin support.
  • ryot - Track various aspects of your life.

Configuration

Requires the config Cargo feature, which is enabled by default.

The primary feature of Schematic is a layered serde-driven configuration solution, and is powered through the Config and ConfigEnum traits, and their associated derive macro. These macros help to generate and automate the following (when applicable):

The struct or enum that derives Config represents the final state, after all partial layers have been merged, and default and environment variable values have been applied. This means that all fields (settings) should not be wrapped in Option, unless the setting is truly optional (think nullable in the config file).

#![allow(unused)]
fn main() {
#[derive(Config)]
struct ExampleConfig {
	pub number: usize,
	pub string: String,
	pub boolean: bool,
	pub array: Vec<String>,
	pub optional: Option<String>,
}
}

This pattern provides the optimal developer experience, as you can reference the settings as-is, without having to unwrap them, or use match or if-let statements!

Usage

Define a struct or enum and derive the Config trait. Fields within the struct (known as settings) can be annotated with the #[setting] attribute to provide additional functionality.

#![allow(unused)]
fn main() {
use schematic::Config;

#[derive(Config)]
struct AppConfig {
	#[setting(default = 3000, env = "PORT")]
	pub port: usize,

	#[setting(default = true)]
	pub secure: bool,

	#[setting(default = vec!["localhost".into()])]
	pub allowed_hosts: Vec<String>,
}
}

Loading sources

When all of your structs and enums have been defined, you can then load, parse, merge, and validate a configuration from one or many sources. A source is either a file path, secure URL, or inline code string.

Begin by importing the ConfigLoader struct and initializing it with the Config type you want to load.

#![allow(unused)]
fn main() {
use schematic::ConfigLoader;

let loader = ConfigLoader::<AppConfig>::new();
}

From here, you can feed it sources to load. For file paths, use the ConfigLoader::file() or ConfigLoader::file_optional() methods. For URLs, use the ConfigLoader::url() method (requires the url Cargo feature, which is on by default). For inline code, use the ConfigLoader::code() method, which requires an explicit format.

#![allow(unused)]
fn main() {
use schematic::Format;

loader.code("secure: false", Format::Yaml)?;
loader.file("path/to/config.yml")?;
loader.url("https://ordomain.com/to/config.yaml")?;
}

The format for files and URLs are derived from the trailing extension.

And lastly call the ConfigLoader::load() method to generate the final configuration. This methods returns a result, which includes the final configuration, as well as all of the partial layers that were loaded.

#![allow(unused)]
fn main() {
let result = loader.load()?;

result.config; // AppConfig
result.layers; // Vec<Layer<PartialAppConfig>>
}

Automatic schemas

When the schema Cargo feature is enabled, the Schematic trait will be automatically implemented for all types that implement Config and ConfigEnum. You do not and should not derive both of these together.

#![allow(unused)]
fn main() {
// Correct
#[derive(Config)]
struct AppConfig {}

// Incorrect
#[derive(Config, Schematic)]
struct AppConfig {}
}

Supported source formats

Schematic is powered entirely by serde, and supports the following formats:

  • JSON - Uses serde_json and requires the json Cargo feature.
  • Pkl (experimental) - Uses rpkl and requires the pkl Cargo feature.
  • TOML - Uses toml and requires the toml Cargo feature.
  • YAML - Uses serde_yaml and requires the yaml Cargo feature.

Cargo features

The following Cargo features are available:

  • config (default) - Enables configuration support (all the above stuff).
  • env (default) - Enables environment variables for settings.
  • extends (default) - Enables configs to extend other configs.
  • json - Enables JSON.
  • pkl - Enables Pkl.
  • toml - Enables TOML.
  • tracing - Wrap generated code in tracing instrumentations.
  • url - Enables loading, extending, and parsing configs from URLs.
  • validate (default) - Enables setting value validation.
  • yaml - Enables YAML.

Settings

Settings are the individual fields of a Config struct or variants of a Config enum, and can be annotated with the optional #[setting] attribute.

Attribute fields

The following fields are supported for the #[setting] field/variant attribute:

And the following for serde compatibility:

  • alias
  • flatten
  • rename
  • skip
  • skip_deserializing
  • skip_serializing

Serde support

A handful of serde attribute fields are currently supported (above) and will apply a #[serde] attribute to the partial implementation.

#![allow(unused)]
fn main() {
#[derive(Config)]
struct Example {
	#[setting(rename = "type")]
	pub type_of: SomeEnum,
}
}

These values can also be applied using #[serde], which is useful if you want to apply them to the main struct as well, and not just the partial struct.

Partials

A powerful feature of Schematic is what we call partial configurations. These are a mirror of the derived Config struct or Config enum, with all settings wrapped in Option, the item name prefixed with Partial, and have common serde and derive attributes automatically applied.

For example, the ExampleConfig from the first chapter would generate the following partial struct:

#![allow(unused)]
fn main() {
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
pub struct PartialExampleConfig {
	#[serde(skip_serializing_if = "Option::is_none")]
	pub number: Option<usize>,

	#[serde(skip_serializing_if = "Option::is_none")]
	pub string: Option<String>,

	#[serde(skip_serializing_if = "Option::is_none")]
	pub boolean: Option<bool>,

	#[serde(skip_serializing_if = "Option::is_none")]
	pub array: Option<Vec<String>>,

	#[serde(skip_serializing_if = "Option::is_none")]
	pub optional: Option<String>,
}
}

So what are partials used for exactly? Partials are used for the entire parsing, layering, extending, and merging process, and ultimately become the final configuration.

When deserializing a source with serde, we utilize the partial config as the target type, because not all fields are guaranteed to be present. This is especially true when merging multiple sources together, as each source may only contain a subset of the final config. Each source represents a layer to be merged.

Partials are also beneficial when serializing, as only settings with values will be written to the source, instead of everything! A common complaint of serde’s strictness.

As stated above, partials also handle the following:

Nesting

Config structs can easily be nested within other Configs using the #[setting(nested)] attribute. Children will be deeply merged and validated alongside the parent.

#![allow(unused)]
fn main() {
#[derive(Config)]
struct ChildConfig {
	// ...
}

#[derive(Config)]
struct ParentConfig {
	#[setting(nested)]
	pub nested: ChildConfig,

	#[setting(nested)]
	pub optional_nested: Option<ChildConfig>,
}

#[derive(Config)]
enum ParentEnum {
	#[setting(nested)]
	Variant(ChildConfig),
}
}

The #[setting(nested)] attribute is required, as the macro will substitute Config with its partial implementation.

Nested values can also be wrapped in collections, like Vec and HashMap. However, these are tricky to support and may not work in all situations!

Bare structs

For structs that do not implement the Config trait, you can use them as-is without the #[setting(nested)] attribute. When using bare structs, be aware that all of the functionality provided by our Config trait is not available, like merging and validation.

#![allow(unused)]
fn main() {
struct BareConfig {
	// ...
}

#[derive(Config)]
pub struct ParentConfig {
	pub nested: BareConfig,
}
}

Context

Context is an important mechanism that allows for different default values, merge strategies, and validation rules to be used, for the same configuration struct, depending on context!

To begin, a context is a struct with a default implementation.

#![allow(unused)]
fn main() {
#[derive(Default)]
struct ExampleContext {
	pub some_value: bool,
	pub another_value: usize,
}
}

Context must then be associated with a Config derived struct through the context attribute field.

#![allow(unused)]
fn main() {
#[derive(Config)]
#[config(context = ExampleContext)]
struct ExampleConfig {
	// ...
}
}

And then passed to the ConfigLoader::load_with_context() method.

#![allow(unused)]
fn main() {
let context = ExampleContext {
	some_value: true,
	another_value: 10,
};

let result = ConfigLoader::<ExampleConfig>::new()
	.url(url_to_config)?
	.load_with_context(&context)?;
}

Refer to the default values, merge strategies, and validation rules sections for more information on how to use context.

Structs & enums

The Config trait can be derived for structs and enums.

#![allow(unused)]
fn main() {
#[derive(Config)]
struct AppConfig {
	pub base: String,
	pub port: usize,
	pub secure: bool,
	pub allowed_hosts: Vec<String>,
}

#[derive(Config)]
enum Host {
	Local,
	Remote(HostConfig),
}
}

Enum caveats

Config can only be derived for enums with tuple or unit variants, but not struct/named variants. Why not struct variants? Because with this pattern, the enum acts like a union type. This also allows for Config functionality, like partials, merging, and validation, to be applied to the contents of each variant.

If you’d like to support unit-only enums, you can use the ConfigEnum trait instead.

Attribute fields

The following fields are supported for the #[config] container attribute:

  • allow_unknown_fields - Removes the serde deny_unknown_fields from the partial struct. Defaults to false.
  • context - Sets the struct to be used as the context. Defaults to None.
  • env_prefix - Sets the prefix to use for environment variable mapping. Defaults to None.
  • serde - A nested attribute that sets tagging related fields for the partial. Defaults to None.
#![allow(unused)]
fn main() {
#[derive(Config)]
#[config(allow_unknown_fields, env_prefix = "EXAMPLE_")]
struct ExampleConfig {
	// ...
}
}

And the following for serde compatibility:

  • rename
  • rename_all - Defaults to camelCase.

Serde support

By default the Config macro will apply the following #[serde] to the partial struct. The default and deny_unknown_fields ensure proper parsing and layer merging.

#![allow(unused)]
fn main() {
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
}

However, the deny_unknown_fields and rename_all fields can be customized, and we also support the rename field, both via the top-level #[config] attribute.

#![allow(unused)]
fn main() {
#[derive(Config)]
#[config(allow_unknown_fields, rename = "ExampleConfig", rename_all = "snake_case")]
struct Example {
	// ...
}
}

These values can also be applied using #[serde], which is useful if you want to apply them to the main struct as well, and not just the partial struct.

Default values

In Schematic, there are 2 forms of default values:

  • The first is applied through the partial configuration, is defined with the #[setting] attribute, and is the first layer to be merged.
  • The second is on the final configuration itself, and uses the Default trait to generate the final value if none was provided. This acts more like a fallback.

To define a default value, use the #[setting(default)] attribute. The default attribute field is used for declaring primitive values, like numbers, strings, and booleans, but can also be used for array and tuple literals, as well as function (mainly for from()) and macros calls.

#![allow(unused)]
fn main() {
#[derive(Config)]
struct AppConfig {
	#[setting(default = "/")]
	pub base: String,

	#[setting(default = 3000)]
	pub port: usize,

	#[setting(default = true)]
	pub secure: bool,

	#[setting(default = vec!["localhost".into()])]
	pub allowed_hosts: Vec<String>,
}
}

For enums, the default field takes no value, and simply marks which variant to use as the default.

#![allow(unused)]
fn main() {
#[derive(Config)]
enum Host {
	#[setting(default)]
	Local,
	Remote(HostConfig),
}
}

Handler function

If you need more control or need to calculate a complex value, you can pass a reference to a function to call. This function receives the context as the first argument, and can return an optional value. If None is returned, the Default value will be used instead.

#![allow(unused)]
fn main() {
fn find_unused_port(ctx: &Context) -> DefaultValueResult<usize> {
	let port = do_find()?;

	Ok(Some(port))
}

#[derive(Config)]
struct AppConfig {
	#[setting(default = find_unused_port)]
	pub port: usize,
}
}

Context handling

If you’re not using context, you can use () as the context type, or rely on generic inferrence.

#![allow(unused)]
fn main() {
fn using_unit_type(_: &()) -> DefaultValueResult<usize> {
	// ...
}

fn using_generics<C>(_: &C) -> DefaultValueResult<usize> {
	// ...
}
}

Environment variables

Requires the env Cargo feature, which is enabled by default.

Not supported for enums.

Settings can also inherit values from environment variables via the #[setting(env)] attribute field. When using this, variables take the highest precedence, and are merged as the last layer.

#![allow(unused)]
fn main() {
#[derive(Config)]
struct AppConfig {
	#[setting(default = 3000, env = "PORT")]
	pub port: usize,
}
}

Container prefixes

If you’d prefer to not define env for every setting, you can instead define a prefix on the containing struct using the #[setting(env_prefix)] attribute field. This will define an environment variable for all direct fields in the struct, in the format of “env prefix + field name” in UPPER_SNAKE_CASE.

For example, the environment variable below for port is now APP_PORT.

#![allow(unused)]
fn main() {
#[derive(Config)]
#[config(env_prefix = "APP_")]
struct AppConfig {
	#[setting(default = 3000)]
	pub port: usize,
}
}

Nested prefixes

Since env_prefix only applies to direct fields and not for nested/children structs, you’ll need to define env_prefix for each struct, and manually set the prefixes. Schematic does not concatenate the prefixes between parent and child.

#![allow(unused)]
fn main() {
#[derive(Config)]
#[config(env_prefix = "APP_SERVER_")]
struct AppServerConfig {
	// ...
}

#[derive(Config)]
#[config(env_prefix = "APP_")]
struct AppConfig {
	#[setting(nested)]
	pub server: AppServerConfig,
}
}

Parsing values

We also support parsing environment variables into the required type. For example, the variable may be a comma separated list of values, or a JSON string.

The #[setting(parse_env)] attribute field can be used, which requires a path to a function to handle the parsing, and receives the variable value as a single argument.

#![allow(unused)]
fn main() {
#[derive(Config)]
struct AppConfig {
	#[setting(env = "ALLOWED_HOSTS", parse_env = schematic::env::split_comma)]
	pub allowed_hosts: Vec<String>,
}
}

We provide a handful of built-in parsing functions in the env module.

Parse handler function

You can also define your own function for parsing values out of environment variables.

When defining a custom parse_env function, the variable value is passed as the 1st argument. A None value can be returned, which will fallback to the previous or default value.

#![allow(unused)]
fn main() {
pub fn custom_parse(var: String) -> ParseEnvResult<ReturnValue> {
	do_parse()
		.map(|v| Some(v))
		.map_err(|e| HandlerError::new(e.to_string()))
}

#[derive(Config)]
struct ExampleConfig {
	#[setting(env = "FIELD", parse_env = custom_parse)]
	pub field: String,
}
}

Extendable sources

Requires the extends Cargo feature, which is enabled by default.

Not supported for enums.

Configs can extend other configs, generating an accurate layer chain, via the #[setting(extend)] attribute field. Extended configs can either be a file path (relative from the current config) or a secure URL.

When defining extend, we currently support 3 types of patterns. We also suggest making the setting optional, so that extending is not required by consumers!

Single source

The first pattern is with a single string, which only allows a single file or URL to be extended.

#![allow(unused)]
fn main() {
#[derive(Config)]
struct AppConfig {
	#[setting(extend, validate = schematic::validate::extends_string)]
	pub extends: Option<String>,
}
}

Example:

extends: "./another/file.yml"

Multiple sources

The second pattern is with a list of strings, allowing multiple files or URLs to be extended. Each item in the list is merged from top to bottom (lowest precedence to highest).

#![allow(unused)]
fn main() {
#[derive(Config)]
struct AppConfig {
	#[setting(extend, validate = schematic::validate::extends_list)]
	pub extends: Option<Vec<String>>,
}
}

Example:

extends:
  - "./another/file.yml"
  - "https://domain.com/some/other/file.yml"

Either pattern

And lastly, supporting both a string or a list, using our built-in enum.

#![allow(unused)]
fn main() {
#[derive(Config)]
struct AppConfig {
	#[setting(extend, validate = schematic::validate::extends_from)]
	pub extends: Option<schematic::ExtendsFrom>,
}
}

Merge strategies

A common requirement for configuration is to merge multiple sources/layers into a final result. By default Schematic will replace the previous setting value with the next value if the next value is Some, but sometimes you want far more control, like shallow or deep merging collections.

This can be achieved with the #[setting(merge)] attribute field, which requires a reference to a function to call.

#![allow(unused)]
fn main() {
#[derive(Config)]
struct AppConfig {
	#[setting(merge = schematic::merge::append_vec)]
	pub allowed_hosts: Vec<String>,
}

#[derive(Config)]
enum Projects {
	#[setting(merge = schematic::merge::append_vec)]
	List(Vec<String>),
	// ...
}
}

We provide a handful of built-in merge functions in the merge module.

Merge handler function

You can also define your own function for merging values.

When defining a custom merge function, the previous value, next value, and context are passed as arguments, and the function must return an optional merged result. If None is returned, neither value will be used.

Here’s an example of the merge function above.

#![allow(unused)]
fn main() {
fn append_vec<T>(mut prev: Vec<T>, next: Vec<T>, context: &Context) -> MergeResult<Vec<T>>> {
    prev.extend(next);

    Ok(Some(prev))
}

#[derive(Config)]
struct ExampleConfig {
	#[setting(merge = append_vec)]
	pub field: Vec<String>,
}
}

Context handling

If you’re not using context, you can use () as the context type, or rely on generic inferrence.

#![allow(unused)]
fn main() {
fn using_unit_type<T>(prev: T, next: T, _: &()) -> MergeResult<T> {
	// ...
}

fn using_generics<T, C>(prev: T, next: T, _: &C) -> MergeResult<T> {
	// ...
}
}

Validation rules

Requires the validate Cargo feature, which is enabled by default.

What kind of configuration crate would this be without built-in validation? As such, we support it as a first-class feature, with built-in validation rules provided by the garde crate.

In Schematic, validation does not happen as part of the serde parsing process, and instead happens for each partial configuration to be merged. However, with that said, prefer serde parsing over validation rules for structural adherence (learn more).

Validation can be applied on a per-setting basis with the #[setting(validate)] attribute field, which requires a reference to a function to call.

#![allow(unused)]
fn main() {
#[derive(Config)]
struct AppConfig {
	#[setting(validate = schematic::validate::alphanumeric)]
	pub secret_key: String,

	#[setting(validate = schematic::validate::regex("^\.env"))]
	pub env_file: String,
}
}

Or on a per-variant basis when using an enum.

#![allow(unused)]
fn main() {
#[derive(Config)]
enum Projects {
	#[setting(validate = schematic::validate::min_length(1))]
	List(Vec<String>),
	// ...
}
}

We provide a handful of built-in validation functions in the validate module. Furthermore, some functions are factories which can be called to produce a validator.

Validate handler function

You can also define your own function for validating values, also known as a validator.

When defining a custom validate function, the value to check is passed as the first argument, the current/parent partial as the second, the context as the third, and whether this is the final validation pass.

#![allow(unused)]
fn main() {
fn validate_string(
	value: &str,
	partial: &PartialAppConfig,
	context: &Context
	finalize: bool
) -> ValidateResult {
	if !do_check(value) {
		return Err(ValidateError::new("Some failure message"));
	}

	Ok(())
}
}

If validation fails, you must return a ValidateError with a failure message.

Factories

For composition and reusability concerns, we also support factory functions that can be called to create a unique validator. This can be seen above with schematic::validate::regex. To create your own factory, declare a normal function, with any number of arguments, that returns a Validator.

Using the regex factory as an example, it would look something like this.

#![allow(unused)]
fn main() {
use schematic::Validator;

fn regex<T, P, C>(pattern: &str) -> Validator<T, P, C> {
	let pattern = regex::Regex::new(pattern).unwrap();

	Box::new(move |value, _, _| {
		if !pattern.is_match(value) {
			return Err(ValidateError::new("Some failure message"));
		}

		Ok(())
	})
}
}

Path targeting

If validating an item in a list or collection, you can specifiy the nested path when failing. This is extremely useful when building error messages.

#![allow(unused)]
fn main() {
use schematic::PathSegment;

ValidateError::with_segments(
	"Some failure message",
	// [i].key
	[PathSegment::Index(i), PathSegment::Key(key.to_string())]
)
}

Context and partial handling

If you’re not using context, or want to create a validator for any kind of partial, we suggest generic inferrence.

#![allow(unused)]
fn main() {
fn using_generics<P, C>(value: &str, partial: &P, context: &C, finalize: bool) -> ValidateResult {
	// ...
}
}

Cargo features

The following Cargo features can be enabled for more functionality:

  • validate_email - Enables email validation with the schematic::validate::email function.
  • validate_url - Enables URL validation with the schematic::validate::url and url_secure functions.

Unit-only enums

Configurations typically use enums to support multiple values within a specific setting. To simplify this process, and to provide streamlined interoperability with Config, we offer a ConfigEnum trait and macro that can be derived for enums with unit-only variants.

#![allow(unused)]
fn main() {
#[derive(ConfigEnum)]
enum LogLevel {
	Info,
	Error,
	Debug,
	Off
}
}

When paired with Config, it’ll look like:

#![allow(unused)]
fn main() {
#[derive(Config)]
struct AppConfig {
	pub log_level: LogLevel
}
}

This enum will generate the following implementations:

  • Provides a static T::variants() method, that returns a list of all variants. Perfect for iteration.
  • Implements FromStr and TryFrom for parsing from a string.
  • Implements Display for formatting into a string.

Attribute fields

The following fields are supported for the #[config] container attribute:

  • before_parse - Transform the variant string value before parsing. Supports lowercase or UPPERCASE.
#![allow(unused)]
fn main() {
#[derive(ConfigEnum)]
#[config(before_parse = "UPPERCASE")]
enum ExampleEnum {
	// ...
}
}

And the following for serde compatibility:

  • rename
  • rename_all - Defaults to kebab-case.

Variants

The following fields are supported for the #[variant] variant attribute:

  • fallback - Marks the variant as the fallback.
  • value - Overrides (explicitly sets) the string value used for parsing and formatting. This is similar to serde’s rename.

And the following for serde compatibility:

  • alias
  • rename

Deriving common traits

All enums (not just unit-only enums) typically support the same derived traits, like Clone, Eq, etc. To reduce boilerplate, we offer a derive_enum! macro that will apply these traits for you.

#![allow(unused)]
fn main() {
derive_enum!(
	#[derive(ConfigEnum)]
	enum LogLevel {
		Info,
		Error,
		Debug,
		Off
	}
);
}

This macro will inject the following attributes:

#![allow(unused)]
fn main() {
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
}

Default variant

To define a default variant, use the Default trait and the optional #[default] variant attribute. We provide no special functionality or syntax for handling defaults.

#![allow(unused)]
fn main() {
#[derive(ConfigEnum, Default)]
enum LogLevel {
	Info,
	Error,
	Debug,
	#[default]
	Off
}
}

Fallback variant

Although ConfigEnum only supports unit variants, we do support a catch-all variant known as the “fallback variant”, which can be defined with #[variant(fallback)]. Fallback variants are primarily used when parsing from a string, and will be used if no other variant matches.

#![allow(unused)]
fn main() {
#[derive(ConfigEnum)]
enum Value {
	Foo,
	Bar,
	Baz
	#[variant(fallback)]
	Other(String)
}
}

However, this pattern does have a few caveats:

  • Only 1 fallback variant can be defined.
  • The fallback variant must be a tuple variant with a single field.
  • The field type can be anything and we’ll attempt to convert it with try_into().
  • The fallback inner value is not casing formatted based on serde’s rename_all.
#![allow(unused)]
fn main() {
let qux = Value::from_str("qux")?; // Value::Other("qux")
}

Experimental

Pkl configuration format (>= v0.17)

Thanks to the rpkl crate, we have experimental support for the Pkl configuration language. Pkl is a dynamic and programmable configuration format built and maintained by Apple.

port = 3000
secure = true
allowedHosts = List(".localhost")

Pkl support can be enabled with the pkl Cargo feature.

Caveats

Unlike our other static formats, Pkl requires the following to work correctly:

  • The pkl binary must exist on PATH. This requires every user to install Pkl onto their machine.
  • Pkl parses local file system paths only.
    • Passing source code directly to ConfigLoader is NOT supported.
    • Reading configuration from URLs is NOT supported, but can be worked around by implementing a custom file-based Cacher.

Known issues

  • The rpkl crate is relatively new and may be buggy or have missing/incomplete functionality.
  • When parsing fails and a code snippet is rendered in the terminal using miette, the line/column offset may not be accurate.

Schemas

Requires the schema Cargo feature, which is not enabled by default.

The other feature of Schematic is the ability to model schemas for Rust types using the Schematic trait and associated macro. Schemas are useful for:

  • Generating code, documentation, and other formats.
  • Ensuring data integrity across systems.
  • Standardizing interoperability and enforcing contracts.

Usage

Define a struct, enum, or type and derive the Schematic trait. Our macro will attempt to convert all fields, variants, values, and generics into a schema representation using SchemaType.

#![allow(unused)]
fn main() {
use schematic::Schematic;

#[derive(Schematic)]
enum UserStatus {
	Active,
	Inactive,
}

#[derive(Schematic)]
struct User {
	pub name: String;
	pub age: usize;
	pub status: UserStatus;
}
}

Once a type has a schema associated with it, it can be fed into the generator.

Custom implementation

Our derive macro will always implement schemas using the default state of Schema, SchemaType, and associated types. If you want these types to use custom settings, you can implement the Schematic trait and Schematic::build_schema() method manually.

The Schematic::schema_name() method is optional, but is encouraged for non-primitive types. It will associate references between types, and avoid circular references.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::*};

#[derive(Schematic)]
enum UserStatus {
	Active,
	Inactive,
}

struct User {
	pub name: String;
	pub age: usize;
	pub status: UserStatus;
}

impl Schematic for User {
	fn schema_name() -> Option<String> {
		Some("User".into())
	}

	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.structure(StructType::new([
			("name".into(), schema.nest().string(StringType {
				min_length: Some(1),
				..StringType::default()
			})),
			("age".into(), schema.nest().integer(IntegerType::new_kind(IntegerKind::Usize))),
			("status".into(), schema.infer::<UserStatus>()),
		]))
	}
}
}

Learn more about our supported types.

Cargo features

The following Cargo features are available:

Renderers

Learn more about renderers.

  • renderer_json_schema - Enables JSON schema generation.
  • renderer_template - Enables config template generation.
  • renderer_typescript - Enables TypeScript types generation.

External types

Learn more about external types.

  • type_chrono - Implements schematic for the chrono crate.
  • type_indexmap - Implements schematic for the indexmap crate.
  • type_regex - Implements schematic for the regex crate.
  • type_relative_path - Implements schematic for the relative-path crate.
  • type_rust_decimal - Implements schematic for the rust_decimal crate.
  • type_semver - Implements schematic for the semver crate.
  • type_url - Implements schematic for the url crate.

Types

Schema types are the building blocks when modeling your schema. They are used to define the explicit shape of your types, data, or configuration. This type information is then passed to a generator, which can then generate and render the schema types in a variety of formats.

Defining names

Schemas can be named, which is useful for referencing them in other types when generating code. By default the Schematic derive macro will use the name of the type, but when implementing the trait manually, you can use the Schematic::schema_name() method.

#![allow(unused)]
fn main() {
impl Schematic for T {
	fn schema_name() -> Option<String> {
		Some("CustomName".into())
	}
}
}

This method is optional, but is encouraged for non-primitive types. It will associate references between types, and avoid circular references.

Inferring schemas

When building a schema, you’ll almost always need to reference schemas from other types that implement Schematic. To do so, you can use the SchemaBuilder.infer::<T>() method, which will create a nested builder, and build an isolated schema based on its implementation.

#![allow(unused)]
fn main() {
struct OtherType {}

impl Schematic for OtherType {
	// ...
}


impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		let builtin_type = schema.infer::<String>();
		let custom_type = schema.infer::<OtherType>();

		// ...
	}
}
}

Creating nested schemas

When building a schema, you may have situations where you need to build nested schemas, for example, within struct fields. You cannot use the type-based methods on SchemaBuilder, as they mutate the current builder. Instead you must created another builder, which can be achieved with the SchemaBuilder.nest() method.

#![allow(unused)]
fn main() {
impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		// Mutates self
		schema.string_default();

		// Creates a new builder and mutates it
		schema.nest().string_default();

		// ...
	}
}
}

Arrays

The ArrayType can be used to represent a variable list of homogeneous values of a given type, as defined by items_type. For example, a list of strings:

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::ArrayType};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.array(ArrayType {
			items_type: Box::new(schema.infer::<String>()),
			..ArrayType::default()
		})
	}
}
}

If you’re only defining the items_type field, you can use the shorthand ArrayType::new() method.

#![allow(unused)]
fn main() {
schema.array(ArrayType::new(schema.infer::<String>()));
}

Automatically implemented for Vec, BTreeSet, HashSet, [T; N], and &[T].

Settings

The following fields can be passed to ArrayType, which are then fed into the generator.

Contains

The contains field can be enabled to indicate that the array must contain at least one item of the type defined by items_type, instead of all items.

#![allow(unused)]
fn main() {
ArrayType {
	// ...
	contains: Some(true),
}
}

Length

The min_length and max_length fields can be used to restrict the length of the array. Both fields accept a non-zero number, and can be used together or individually.

#![allow(unused)]
fn main() {
ArrayType {
	// ...
	min_length: Some(1),
	max_length: Some(10),
}
}

Uniqueness

The unique field can be used to indicate that all items in the array must be unique. Note that Schematic does not verify uniqueness.

#![allow(unused)]
fn main() {
ArrayType {
	// ...
	unique: Some(true),
}
}

Booleans

The BooleanType can be used to represent a boolean true or false value. Values that evaluate to true or false, such as 1 and 0, are not accepted by the schema.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::BooleanType};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.boolean_default()
	}
}
}

Automatically implemented for bool.

Default value

To customize the default value for use within generators, pass the desired value to the BooleanType constructor.

#![allow(unused)]
fn main() {
schema.boolean(BooleanType::new(true));
}

Enums

The EnumType can be used to represent a list of literal values.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{EnumType, LiteralValue}};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.enumerable(EnumType {
			values: vec![
				LiteralValue::String("debug".into()),
				LiteralValue::String("error".into()),
				LiteralValue::String("warning".into()),
			],
			..EnumType::default()
		})
	}
}
}

If you’re only defining the values field, you can use the shorthand EnumType::new() method.

#![allow(unused)]
fn main() {
schema.enumerable(EnumType::new([
	LiteralValue::String("debug".into()),
	LiteralValue::String("error".into()),
	LiteralValue::String("warning".into()),
]));
}

Detailed variants

If you’d like to provide more detailed information for each variant (value), like descriptions and visibility, you can define the variants field and pass a map of SchemaFields.

#![allow(unused)]
fn main() {
schema.enumerable(EnumType {
	values: vec![
		LiteralValue::String("debug".into()),
		LiteralValue::String("error".into()),
		LiteralValue::String("warning".into()),
	],
	variants: Some(IndexMap::from_iter([
		(
			"Debug".into(),
			SchemaField {
				comment: Some("Shows debug messages and above".into()),
				schema: Schema::new(SchemaType::literal(LiteralValue::String("debug".into()))),
				..SchemaField::default()
			}
		),
		(
			"Error".into(),
			SchemaField {
				comment: Some("Shows only error messages".into()),
				schema: Schema::new(SchemaType::literal(LiteralValue::String("error".into()))),
				..SchemaField::default()
			}
		),
		(
			"Warning".into(),
			SchemaField {
				comment: Some("Shows warning and error messages".into()),
				schema: Schema::new(SchemaType::literal(LiteralValue::String("warning".into()))),
				..SchemaField::default()
			}
		),
	])),
	..EnumType::default()
})
}

This comes in handy when working with specific generators, like TypeScript.

Floats

The FloatType can be used to represent a float or double.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{FloatType, FloatKind}};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.float(FloatType {
			kind: FloatKind::F32,
			..FloatType::default()
		})
	}
}
}

If you’re only defining the kind field, you can use the shorthand FloatType::new_kind() method.

#![allow(unused)]
fn main() {
schema.float(FloatType::new_kind(FloatKind::F32));
}

Automatically implemented for f32 and f64.

Default value

To customize the default value for use within generators, pass the desired value to the FloatType constructor.

#![allow(unused)]
fn main() {
schema.float(FloatType::new_32(32.0));
// Or
schema.float(FloatType::new_64(64.0));
}

Settings

The following fields can be passed to FloatType, which are then fed into the generator.

Enumerable

The enum_values field can be used to specify a list of literal values that are allowed for the field.

#![allow(unused)]
fn main() {
FloatType {
	// ...
	enum_values: Some(vec![0.0, 0.25, 0.5, 0.75, 1.0]),
}
}

Formats

The format field can be used to associate semantic meaning to the float, and how the float will be used and displayed.

#![allow(unused)]
fn main() {
FloatType {
	// ...
	format: Some("currency".into()),
}
}

This is primarily used by JSON Schema.

Min/max

The min and max fields can be used to specify the minimum and maximum inclusive values allowed. Both fields accept a non-zero number, and can be used together or individually.

#![allow(unused)]
fn main() {
FloatType {
	// ...
	min: Some(0.0), // >0
	max: Some(1.0), // <1
}
}

These fields are not exclusive and do not include the lower and upper bound values. To include them, use min_exclusive and max_exclusive instead.

#![allow(unused)]
fn main() {
FloatType {
	// ...
	min_exclusive: Some(0.0), // >=0
	max_exclusive: Some(1.0), // <=1
}
}

Multiple of

The multiple_of field can be used to specify a value that the float must be a multiple of.

#![allow(unused)]
fn main() {
FloatType {
	// ...
	multiple_of: Some(0.25), // 0.0, 0.25, 0.50, etc
}
}

Integers

The IntegerType can be used to represent an integer (number).

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{IntegerType, IntegerKind}};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.integer(IntegerType {
			kind: IntegerKind::U32,
			..IntegerType::default()
		})
	}
}
}

If you’re only defining the kind field, you can use the shorthand IntegerType::new_kind() method.

#![allow(unused)]
fn main() {
schema.integer(IntegerType::new_kind(IntegerKind::U32));
}

Automatically implemented for usize-u128 and isize-i128.

Default value

To customize the default value for use within generators, pass the desired value to the IntegerType constructor.

#![allow(unused)]
fn main() {
schema.integer(IntegerType::new(IntegerKind::I32, 100));
// Or
schema.integer(IntegerType::new_unsigned(IntegerKind::U32, 100));
}

Settings

The following fields can be passed to IntegerType, which are then fed into the generator.

Enumerable

The enum_values field can be used to specify a list of literal values that are allowed for the field.

#![allow(unused)]
fn main() {
IntegerType {
	// ...
	enum_values: Some(vec![0, 25, 50, 75, 100]),
}
}

Formats

The format field can be used to associate semantic meaning to the integer, and how the integer will be used and displayed.

#![allow(unused)]
fn main() {
IntegerType {
	// ...
	format: Some("age".into()),
}
}

This is primarily used by JSON Schema.

Min/max

The min and max fields can be used to specify the minimum and maximum inclusive values allowed. Both fields accept a non-zero number, and can be used together or individually.

#![allow(unused)]
fn main() {
IntegerType {
	// ...
	min: Some(0), // >0
	max: Some(100), // <100
}
}

These fields are not exclusive and do not include the lower and upper bound values. To include them, use min_exclusive and max_exclusive instead.

#![allow(unused)]
fn main() {
IntegerType {
	// ...
	min_exclusive: Some(0), // >=0
	max_exclusive: Some(100), // <=100
}
}

Multiple of

The multiple_of field can be used to specify a value that the integer must be a multiple of.

#![allow(unused)]
fn main() {
IntegerType {
	// ...
	multiple_of: Some(25), // 0, 25, 50, etc
}
}

Literals

The LiteralType can be used to represent a literal primitive value, such as a string or number.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{LiteralType, LiteralValue}};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.literal(LiteralType::new(LiteralValue::String("enabled".into())))
		// Or
		schema.literal_value(LiteralValue::String("enabled".into()))
	}
}
}

The LiteralValue type is used by other schema types for their default or enumerable values.

Nulls

The SchemaType::Null variant can be used to represent a literal null value. This works best when paired with unions or fields that need to be nullable.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.set_type_and_build(SchemaType::Null)
	}
}
}

Automatically implemented for () and Option<T>.

Marking as nullable

If you want a concrete schema to also accept null (an Optional value), you can use the SchemaBuilder::nullable() method. Under the hood, this will create a union of the defined type, and the null type.

#![allow(unused)]
fn main() {
// string | null
schema.nullable(schema.infer::<String>());
}

Objects

The ObjectType can be used to represent a key-value object of homogenous types. This is also known as a map, record, keyed object, or indexed object.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::ObjectType};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.object(ObjectType {
			key_type: Box::new(schema.infer::<String>()),
			value_type: Box::new(schema.infer::<String>()),
			..ObjectType::default()
		})
	}
}
}

If you’re only defining the key_type and value_type fields, you can use the shorthand ObjectType::new() method.

#![allow(unused)]
fn main() {
schema.object(ObjectType::new(schema.infer::<String>(), schema.infer::<String>()));
}

Automatically implemented for BTreeMap and HashMap.

Settings

The following fields can be passed to ObjectType, which are then fed into the generator.

Length

The min_length and max_length fields can be used to restrict the length (key-value pairs) of the object. Both fields accept a non-zero number, and can be used together or individually.

#![allow(unused)]
fn main() {
ObjectType {
	// ...
	min_length: Some(1),
	max_length: Some(10),
}
}

Required keys

The required field can be used to specify a list of keys that are required for the object, and must exist when the object is validated.

#![allow(unused)]
fn main() {
ObjectType {
	// ...
	required: Some(vec!["foo".into(), "bar".into()]),
}
}

This is primarily used by JSON Schema.

Strings

The StringType can be used to represent a sequence of bytes, you know, a string.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{StringType, IntegerKind}};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.string_default()
	}
}
}

Automatically implemented for char, str, String, Path, PathBuf, Ipv4Addr, Ipv6Addr, SystemTime, and Duration.

Default value

To customize the default value for use within generators, pass the desired value to the StringType constructor.

#![allow(unused)]
fn main() {
schema.string(StringType::new("abc"));
}

Settings

The following fields can be passed to StringType, which are then fed into the generator.

Enumerable

The enum_values field can be used to specify a list of literal values that are allowed for the field.

#![allow(unused)]
fn main() {
StringType {
	// ...
	enum_values: Some(vec!["a".into(), "b".into(), "c".into()]),
}
}

Formats

The format field can be used to associate semantic meaning to the string, and how the string will be used and displayed.

#![allow(unused)]
fn main() {
StringType {
	// ...
	format: Some("url".into()),
}
}

This is primarily used by JSON Schema.

Length

The min_length and max_length fields can be used to restrict the length of the string. Both fields accept a non-zero number, and can be used together or individually.

#![allow(unused)]
fn main() {
StringType {
	// ...
	min_length: Some(1),
	max_length: Some(10),
}
}

Patterns

The pattern field can be used to define a regex pattern that the string must abide by.

#![allow(unused)]
fn main() {
StringType {
	// ...
	format: Some("version".into()),
	pattern: Some("\d+\.\d+\.\d+".into()),
}
}

This is primarily used by JSON Schema.

Structs

The StructType can be used to represent a struct with explicitly named fields and typed values. This is also known as a “shape” or “model”.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::StructType};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.structure(StructType {
			fields: HashMap::from_iter([
				(
					"name".into(),
					Box::new(SchemaField {
						comment: Some("Name of the user".into()),
						schema: schema.infer::<String>(),
						..SchemaField::default()
					})
				),
				(
					"age".into(),
					Box::new(SchemaField {
						comment: Some("Age of the user".into()),
						schema: schema.nest().integer(IntegerType::new_kind(IntegerKind::U16)),
						..SchemaField::default()
					})
				),
				(
					"active".into(),
					Box::new(SchemaField {
						comment: Some("Is the user active".into()),
						schema: schema.infer::<bool>(),
						..SchemaField::default()
					})
				),
			]),
			..StructType::default()
		})
	}
}
}

If you’re only defining fields, you can use the shorthand StructType::new() method. When using this approach, the Boxs are automatically inserted for you.

#![allow(unused)]
fn main() {
schema.structure(StructType::new([
	(
		"name".into(),
		SchemaField {
			comment: Some("Name of the user".into()),
			schema: schema.infer::<String>(),
			..SchemaField::default()
		}
	),
	// ...
]));
}

Settings

The following fields can be passed to StructType, which are then fed into the generator.

Required fields

The required field can be used to specify a list of fields that are required for the struct.

#![allow(unused)]
fn main() {
StructType {
	// ...
	required: Some(vec!["name".into()]),
}
}

This is primarily used by JSON Schema.

Tuples

The TupleType can be used to represent a fixed list of heterogeneous values of a given type, as defined by items_types.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{TupleType, IntegerKind}};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.tuple(TupleType {
			items_types: vec![
				Box::new(schema.infer::<String>()),
				Box::new(schema.infer::<bool>()),
				Box::new(schema.nest().integer(IntegerType::new_kind(IntegerKind::U32))),
			],
			..TupleType::default()
		})
	}
}
}

If you’re only defining the items_types field, you can use the shorthand TupleType::new() method. When using this approach, the Boxs are automatically inserted for you.

#![allow(unused)]
fn main() {
schema.tuple(TupleType::new([
	schema.infer::<String>(),
	schema.infer::<bool>(),
	schema.nest().integer(IntegerType::new_kind(IntegerKind::U32)),
]));
}

Automatically implemented for tuples of 0-12 length.

Unions

The UnionType paired with SchemaType::Union can be used to represent a list of heterogeneous schema types (variants), in which a value must match one or more of the types.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType, schema::{UnionType, UnionOperator}};

impl Schematic for T {
	fn build_schema(mut schema: SchemaBuilder) -> Schema {
		schema.union(UnionType {
			operator: UnionOperator::AnyOf,
			variants_types: vec![
				Box::new(schema.infer::<String>()),
				Box::new(schema.infer::<bool>()),
				Box::new(schema.nest().integer(IntegerType::new_kind(IntegerKind::U32))),
			],
			..UnionType::default()
		})
	}
}
}

If you’re only defining the variants_types field, you can use the shorthand UnionType::new_any() (any of) or UnionType::new_one() (one of) methods. When using this approach, the Boxs are automatically inserted for you.

#![allow(unused)]
fn main() {
// Any of
schema.union(UnionType::new_any([
	schema.infer::<String>(),
	schema.infer::<bool>(),
	schema.nest().integer(IntegerType::new_kind(IntegerKind::U32)),
]));

// One of
schema.union(UnionType::new_one([
	// ...
]));
}

Operators

Unions support 2 kinds of operators, any of and one of, both of which can be defined with the operator field.

  • Any of requires the value to match any of the variants.
  • One of requires the value to match only one of the variants.
#![allow(unused)]
fn main() {
UnionType {
	// ...
	operator: UnionOperator::OneOf,
}
}

Unknown

The SchemaType::Unknown variant can be used to represent an unknown type. This is sometimes known as an “any” or “mixed” type.

#![allow(unused)]
fn main() {
use schematic::{Schematic, Schema, SchemaBuilder, SchemaType};

impl Schematic for T {
	fn build_schema(schema: SchemaBuilder) -> Schema {
		schema.build()
	}
}
}

The SchemaType::Unknown variant is also the default variant, and the default implementation for Schematic::build_schema(), so the above can simply be written as:

#![allow(unused)]
fn main() {
impl Schematic for T {}
}

External types

Schematic provides schema implementations for third-party crates through a concept known as external types. This functionality is opt-in through Cargo features.

chrono

Requires the type_chrono Cargo feature.

Implements schemas for Date, DateTime, Duration, Days, Months, IsoWeek, NaiveWeek, NaiveDate, NaiveDateTime, and NaiveTime from the chrono crate.

indexmap

Requires the type_indexmap Cargo feature.

Implements a schema for IndexMap and IndexSet from the indexmap crate.

regex

Requires the type_regex Cargo feature.

Implements a schema for Regex from the regex crate.

relative-path

Requires the type_relative_path Cargo feature.

Implements schemas for RelativePath and RelativePathBuf from the relative-path crate.

rpkl

Requires the type_serde_rpkl Cargo feature.

Implements schemas for Value from the rpkl crate.

rust_decimal

Requires the type_rust_decimal Cargo feature.

Implements a schema for Decimal from the rust_decimal crate.

semver

Requires the type_semver Cargo feature.

Implements schemas for Version and VersionReq from the semver crate.

serde_json

Requires the type_serde_json Cargo feature.

Implements schemas for Value, Number, and Map from the serde_json crate.

serde_yaml

Requires the type_serde_yaml Cargo feature.

Implements schemas for Value, Number, and Mapping from the serde_yaml crate.

toml

Requires the type_serde_toml Cargo feature.

Implements schemas for Value and Map from the toml crate.

url

Requires the type_url Cargo feature.

Implements a schema for Url from the url crate.

Code generation

The primary benefit of a schema modeling system, is that you can consume this type information to generate code into multiple output formats. This is a common pattern in many languages, and is a great way to reduce boilerplate.

In the context of Rust, why use multiple disparate crates, each with their own unique implementations and #[derive] macros, just to generate some output. With Schematic, you can ditch all of these and use a single standardized approach.

Usage

To make use of the generator, import and instantiate our SchemaGenerator. This is typically done within a one-off main function that can be ran from Cargo.

#![allow(unused)]
fn main() {
use schematic::schema::SchemaGenerator;

let mut generator = SchemaGenerator::default();
}

Adding types

From here, for every type that implements Schematic and you want to include in the generated output, call SchemaGenerator::add(). If you only have a SchemaType, you can use the SchemaGenerator::add_schema() method instead.

#![allow(unused)]
fn main() {
use schematic::schema::SchemaGenerator;

let mut generator = SchemaGenerator::default();
generator.add::<FirstConfig>();
generator.add::<SecondConfig>();
generator.add::<ThirdConfig>();
}

We’ll recursively add referenced and nested schemas for types that are added. No need to explicitly add all required types!

Generating output

From here, call SchemaGenerator::generate() to render the schemes with a chosen renderer to an output file of your choice. This method can be called multiple times, each with a different output file or renderer.

#![allow(unused)]
fn main() {
use schematic::schema::SchemaGenerator;

let mut generator = SchemaGenerator::default();
generator.add::<FirstConfig>();
generator.add::<SecondConfig>();
generator.add::<ThirdConfig>();
generator.generate(PathBuf::from("output/file"), CustomRenderer::default())?;
generator.generate(PathBuf::from("output/another/file"), AnotherRenderer::default())?;
}

Renderers

The following built-in renderers are available, but custom renderers can be created as well by implementing the SchemaRenderer trait.

Config templates (experimental)

Requires the template and desired format Cargo feature.

With our format renderers, you can generate a config template in a specific format. This template will include all fields, default values, comments, metadata, and is useful for situations like scaffolding files during installation.

To utilize, instantiate a generator, add types to render, and generate the output file.

#![allow(unused)]
fn main() {
use schematic::schema::SchemaGenerator;

let mut generator = SchemaGenerator::default();
generator.add::<CustomType>();
generator.generate(output_dir.join("config.json"), renderer)?;
}

Supported formats

JSON

The JsonTemplateRenderer will render JSON templates without comments. Any commented related options will be force disabled.

#![allow(unused)]
fn main() {
use schematic::schema::{JsonTemplateRenderer, TemplateOptions};

JsonTemplateRenderer::default();
JsonTemplateRenderer::new(TemplateOptions::default());
}

JSONC

The JsoncTemplateRenderer will render JSON templates with comments. We suggest using the .jsonc file extension, but not required.

#![allow(unused)]
fn main() {
use schematic::schema::{JsoncTemplateRenderer, TemplateOptions};

JsoncTemplateRenderer::default();
JsoncTemplateRenderer::new(TemplateOptions::default());
}

Pkl

The PklTemplateRenderer will render Pkl templates.

#![allow(unused)]
fn main() {
use schematic::schema::{PklTemplateRenderer, TemplateOptions};

PklTemplateRenderer::default();
PklTemplateRenderer::new(TemplateOptions::default());
}

TOML

The TomlTemplateRenderer will render TOML templates.

#![allow(unused)]
fn main() {
use schematic::schema::{TomlTemplateRenderer, TemplateOptions};

TomlTemplateRenderer::default();
TomlTemplateRenderer::new(TemplateOptions::default());
}

YAML

The YamlTemplateRenderer will render YAML templates.

#![allow(unused)]
fn main() {
use schematic::schema::{YamlTemplateRenderer, TemplateOptions};

YamlTemplateRenderer::default();
YamlTemplateRenderer::new(TemplateOptions::default());
}

Root document

A template represents a single document, typically for a struct. In Schematic, the last type to be added to SchemaGenerator will be the root document, while all other types will be ignored. For example:

#![allow(unused)]
fn main() {
// These are only used for type information
generator.add::<FirstConfig>();
generator.add::<SecondConfig>();
generator.add::<ThirdConfig>();

// This is the root document
generator.add::<LastType>();
generator.generate(output_dir.join("config.json"), renderer)?;
}

Caveats

By default arrays and objects do not support default values, and will render [] and {} respectively. This can be customized with the expand_fields option.

Furthermore, enums and unions only support default values when explicitly marked as such. For example, with #[default].

And lastly, when we’re unsure of what to render for a value, we’ll render null. This isn’t a valid value for TOML, and may not be what you expect.

Example output

Given the following type:

#![allow(unused)]
fn main() {
#[derive(Config)]
struct ServerConfig {
	/// The base URL to serve from.
	#[setting(default = "/")]
	pub base_url: String,

	/// The default port to listen on.
	#[setting(default = 8080, env = "PORT")]
	pub port: usize,
}
}

Would render the following formats:

JSONC Pkl
{
	// The base URL to serve from.
	"base_url": "/",

	// The default port to listen on.
	// @envvar PORT
	"port": 8080
}
# The base URL to serve from.
base_url = "/"

# The default port to listen on.
# @envvar PORT
port = 8080

TOML YAML
# The base URL to serve from.
base_url = "/"

# The default port to listen on.
# @envvar PORT
port = 8080
# The base URL to serve from.
base_url: "/"

# The default port to listen on.
# @envvar PORT
port: 8080

Applying the desired casing for field names should be done with serde rename_all on the container.

Options

Custom options can be passed to the renderer using TemplateOptions.

#![allow(unused)]
fn main() {
use schematic::schema::TemplateOptions;

JsoncTemplateRenderer::new(TemplateOptions {
	// ...
	..TemplateOptions::default()
});
}

The format option is required!

Indentation

The indentation of the generated template can be customized using the indent_char option. By default this is 2 spaces ( ).

#![allow(unused)]
fn main() {
TemplateOptions {
	// ...
	indent_char: "\t".into(),
}
}

The spacing between fields can also be toggled with the newline_between_fields option. By default this is enabled, which adds a newline between each field.

#![allow(unused)]
fn main() {
TemplateOptions {
	// ...
	newline_between_fields: false,
}
}

Comments

All Rust doc comments (///) are rendered as comments above each field in the template. This can be disabled with the comments option.

#![allow(unused)]
fn main() {
TemplateOptions {
	// ...
	comments: false,
}
}

The header and footer options can be customized to add additional content to the top and bottom of the rendered template respectively.

#![allow(unused)]
fn main() {
TemplateOptions {
	// ...
	header: "$schema: \"https://example.com/schema.json\"\n\n".into(),
	footer: "\n\n# Learn more: https://example.com".into(),
}
}

Field display

By default all non-skipped fields in the root document (struct) are rendered in the template. If you’d like to hide certain fields from being rendered, you can use the hide_fields option. This option accepts a list of field names and also supports dot-notation for nested fields.

#![allow(unused)]
fn main() {
TemplateOptions {
	// ...
	hide_fields: vec!["key".into(), "nested.key".into()],
}
}

Additionally, if you’d like to render a field but have it commented out by default, use the comment_fields option instead. This also supports dot-notation for nested fields.

#![allow(unused)]
fn main() {
TemplateOptions {
	// ...
	comment_fields: vec!["key".into(), "nested.key".into()],
}
}

Field names use the serde cased name, not the Rust struct field name.

Field expansion

For arrays and objects, we render an empty value ([] or {}) by default because there’s no actual data associated with the schema. However, if you’d like to render a single example item for a field, you can use the expand_fields option.

#![allow(unused)]
fn main() {
TemplateOptions {
	// ...
	expand_fields: vec!["key".into(), "nested.key".into()],
}
}

Here’s an example of how this works:

Not expanded Expanded
{
	"proxies": []
}
{
	"proxies": [
		// An example proxy configuration.
		{
			"host": "",
			"port": 8080
		}
	]
}

JSON schemas

Requires the json_schema Cargo feature.

With our JsonSchemaRenderer, you can generate a JSON Schema document for all types that implement Schematic. Internally this renderer uses the schemars crate to generate the JSON document.

To utilize, instantiate a generator, add types to render, and generate the output file.

#![allow(unused)]
fn main() {
use schematic::schema::{SchemaGenerator, JsonSchemaRenderer};

let mut generator = SchemaGenerator::default();
generator.add::<CustomType>();
generator.generate(output_dir.join("schema.json"), JsonSchemaRenderer::default())?;
}

For a reference implementation, check out moonrepo/moon.

Root document

Unlike other renderers, a JSON schema represents a single document, with referenced types being organized into definitions. In Schematic, the last type to be added to SchemaGenerator will be the root document, while all other types will become definitions. For example:

#![allow(unused)]
fn main() {
// These are definitions
generator.add::<FirstConfig>();
generator.add::<SecondConfig>();
generator.add::<ThirdConfig>();

// This is the root document
generator.add::<LastType>();
generator.generate(output_dir.join("schema.json"), JsonSchemaRenderer::default())?;
}

When rendered, will look something like the following:

{
	"$schema": "http://json-schema.org/draft-07/schema#",
	"title": "LastType",
	"type": "object",
	"properties": {
		// Fields in LastType...
	},
	"definitions": {
		// Other types...
	}
}

Options

Custom options can be passed to the renderer using JsonSchemaOptions.

#![allow(unused)]
fn main() {
use schematic::schema::JsonSchemaOptions;

JsonSchemaRenderer::new(JsonSchemaOptions {
	// ...
	..JsonSchemaOptions::default()
});
}

This type also contains all fields from the upstream SchemaSettings type from the schemars crate. Refer to their documentation for more information.

Markdown descriptions

By default, the description field in the JSON schema specification is supposed to be a plain text string, but some tools support markdown through another field called markdownDescription.

To support this pattern, enable the markdown_description option, which will inject the markdownDescription field if markdown was detected in the description field.

#![allow(unused)]
fn main() {
JsonSchemaOptions {
	// ...
	markdown_description: true,
}
}

This is a non-standard extension to the JSON schema specification.

Required fields

When a struct is rendered, automatically mark all non-Option struct fields as required, and include them in the JSON schema required field. This is enabled by default.

#![allow(unused)]
fn main() {
JsonSchemaOptions {
	// ...
	mark_struct_fields_required: false,
}
}

Field titles

The JSON schema specification supports a title annotation for each field, which is a human-readable string. By default this is the name of the Rust struct, enum, or type field.

But depending on the tool that consumes the schema, this may not be the best representation. As an alternative, the set_field_name_as_title option can be enabled to use the field name itself as the title.

#![allow(unused)]
fn main() {
JsonSchemaOptions {
	// ...
	set_field_name_as_title: true,
}
}

TypeScript types

Requires the typescript Cargo feature.

With our TypeScriptRenderer, you can generate TypeScript types for all types that implement Schematic. To utilize, instantiate a generator, add types to render, and generate the output file.

#![allow(unused)]
fn main() {
use schematic::schema::{SchemaGenerator, TypeScriptRenderer};

let mut generator = SchemaGenerator::default();
generator.add::<CustomType>();
generator.generate(output_dir.join("types.ts"), TypeScriptRenderer::default())?;
}

For a reference implementation, check out moonrepo/moon.

Options

Custom options can be passed to the renderer using TypeScriptOptions.

#![allow(unused)]
fn main() {
use schematic::schema::TypeScriptOptions;

TypeScriptRenderer::new(TypeScriptOptions {
	// ...
	..TypeScriptOptions::default()
});
}

Indentation

The indentation of the generated TypeScript code can be customized using the indent_char option. By default this is a tab (\t).

#![allow(unused)]
fn main() {
TypeScriptOptions {
	// ...
	indent_char: "  ".into(),
}
}

Enum types

Enum types can be rendered in a format of your choice using the enum_format option and the EnumFormat enum. By default enums are rendered as TypeScript string unions, but can be rendered as TypeScript enums instead.

#![allow(unused)]
fn main() {
TypeScriptOptions {
	// ...
	enum_format: EnumFormat::Enum,
}
}
// Default
export type LogLevel = "debug" | "info" | "error";

// As enum
export enum LogLevel {
	Debug,
	Info,
	Error,
}

Furthermore, the const_enum option can be enabled to render const enum types instead of enum types. This does not apply when EnumFormat::Union is used.

#![allow(unused)]
fn main() {
TypeScriptOptions {
	// ...
	const_enum: true,
}
}
// Enabled
export const enum LogLevel {}

// Disabled
export enum LogLevel {}

Object types

Struct types can be rendered as either TypeScript interfaces or type aliases using the object_format option and the ObjectFormat enum. By default structs are rendered as TypeScript interfaces.

#![allow(unused)]
fn main() {
TypeScriptOptions {
	// ...
	object_format: ObjectFormat::Type,
}
}
// Default
export interface User {
	name: string;
}

// As alias
export type User = {
	name: string;
};

Properties format

Properties within a struct can be rendered as either optional or required in TypeScript, depending on usage. The default format for all properties can be customized with the property_format option and the PropertyFormat enum. By default all properties are required.

#![allow(unused)]
fn main() {
TypeScriptOptions {
	// ...
	property_format: PropertyFormat::Required,
}
}
// Default / required
export interface User {
	name: string;
}

// Optional
export interface User {
	name?: string;
}

// Optional with undefined union
export interface User {
	name?: string | undefined;
}

Type references

In the context of this renderer, a type reference is simply a reference to another type by its name, and is used by other types of another name. For example, the fields of a struct type may reference another type by name.

export type UserStatus = "active" | "inactive";

export interface User {
	status: UserStatus;
}

Depending on your use case, this may not be desirable. If so, you can enable the disable_references option, which disables references entirely, and inlines all type information. So the example above would become:

#![allow(unused)]
fn main() {
TypeScriptOptions {
	// ...
	disable_references: true,
}
}
export type UserStatus = "active" | "inactive";

export interface User {
	status: "active" | "inactive";
}

Additionally, the exclude_references option can be used to exclude a type reference by name entirely from the output, as demonstrated below.

#![allow(unused)]
fn main() {
TypeScriptOptions {
	// ...
	exclude_references: vec!["UserStatus".into()],
}
}
export interface User {
	status: "active" | "inactive";
}

Importing external types

For better interoperability, you can import external types from other TypeScript modules using the external_types option, which is a map of file paths (relative from the output location) to a list of types to import from that file. This is useful if:

  • You have existing types that aren’t generated and want to reference.
  • You want to reference types from other generated files, and don’t want to duplicate them.
#![allow(unused)]
fn main() {
TypeScriptOptions {
	// ...
	external_types: HashMap::from_iter([
		("./states".into(), vec!["UserStatus".into()]),
	]),
}
}
import type { UserStatus } from "./states";

export interface User {
	status: UserStatus;
}