Documentation
¶
Overview ¶
Package dotenv is a comment-preserving .env parser and editor.
Parse-to-map dotenv libraries throw away everything that is not a key or a value, so writing a file back destroys its comments, blank lines, ordering, and quoting style. This package edits LINE-WISE instead: every byte outside the entry you touch survives verbatim. Two invariants, both pinned by tests:
parse → render with no changes ⇒ byte-identical output Set(key, <current value>) ⇒ byte-identical output (a true no-op)
That matters for a file a human wrote and a tool edits. A `.env` is documentation as much as configuration, and a tool that silently strips the comment explaining why a value exists has damaged the file even though every key survived.
What it understands ¶
KEY=value a pair
KEY: value a pair — Compose and Node dotenv accept the colon
delimiter too, and the author's choice is preserved
export KEY=value the export prefix is preserved
KEY a name with no delimiter: a Compose "inherited"
declaration — recognised but never resolved (§ scope)
KEY="quoted value" double quotes; \" and \\ are interpreted
KEY='literal value' single quotes; fully literal
KEY="line one a quoted value whose quote does not close keeps
line two" going, and the whole block is one logical entry
KEY=value # note an inline comment, preserved on edit
# a comment kept verbatim
<blank> kept verbatim
Escape policy ¶
Inside double quotes only `\"` and `\\` are interpreted. There is NO `\n` expansion — a newline in a value is a real newline in the file. Single-quoted values are entirely literal. This mirrors Docker Compose's .env handling rather than shell semantics, because a .env is far more often read by compose than sourced by a shell.
Scope: a parser and editor, not a loader ¶
This package NEVER touches os.Environ. Reading a .env and exporting it into the process are separate decisions, and conflating them is why godotenv.Load means something this package must not mean. A caller that wants the values in its environment applies its own precedence policy over f.Map().
It also does not merge multiple files. Merging is a precedence policy — which file wins, per key — and the whole guarantee here is that one file maps to one set of bytes. Merged content has no single file to render back to, so it could not round-trip. Layer that above this package.
It does not coerce values to types. Every value is a string, because a .env has none — PORT=8443 is four characters, and which Go type that becomes depends on the field it is bound to. A *File satisfies the Lookuper interface used by struct-binding packages with a three-line adapter, so typed configuration composes rather than being reimplemented here. See the README.
It does not stream. Parse and ParseReader both hold the whole file, and three separate features depend on that: a forward reference resolves against a key defined further down, insertion needs an index into a complete entry list, and byte-exact rendering needs every original line. A streaming parser would have to abandon all three.
It would also be dangerous rather than merely limited. Streaming could resolve BACKWARD references from a running map — but with a duplicated key, "last wins" cannot be known until the file ends, so the same bytes would expand to one value here and another there. Two APIs disagreeing about one file is worse than one API doing less.
If the streaming case ever becomes real, the shape it would take is a Scan over physical lines with byte-exactness and forward references dropped — a different, lesser contract, which is why it is not this one.
Naming ¶
Named for the format, the way encoding/json and yaml are — not for the file it reads. The constructor is Open rather than Load because godotenv.Load sets os.Environ, and this package deliberately never touches the environment: it hands back a file you read, edit, and save. A Load here would mean the opposite of every other Load in the ecosystem.
Line endings and permissions ¶
CRLF files render back as CRLF. A file's existing mode is preserved; a new one is created 0600, because .env files hold credentials and a group-readable secret is a leak.
Index ¶
- Variables
- func Read(path string, opts ...Option) (map[string]string, error)
- type Capability
- type CommandRunner
- type Entry
- type EntryObserver
- type EscapeMode
- type ExpandObserver
- type File
- func (f *File) Append(entries ...*Entry)
- func (f *File) Clone() *File
- func (f *File) Count(key string) int
- func (f *File) Disabled() []Pair
- func (f *File) Entries() []*Entry
- func (f *File) Existed() bool
- func (f *File) ExpandedMap() (map[string]string, error)
- func (f *File) Get(key string) (string, bool)
- func (f *File) GetExpanded(key string) (string, bool, error)
- func (f *File) Has(key string) bool
- func (f *File) Inherited() []string
- func (f *File) InsertAfter(anchorKey string, entries ...*Entry) error
- func (f *File) InsertBefore(anchorKey string, entries ...*Entry) error
- func (f *File) Keys() []string
- func (f *File) Map() map[string]string
- func (f *File) Mode() fs.FileMode
- func (f *File) Pairs() []Pair
- func (f *File) Plugins() []PluginInfo
- func (f *File) Render() string
- func (f *File) Restore(key string) bool
- func (f *File) Save() error
- func (f *File) SaveAs(path string) error
- func (f *File) Set(key, value string) (created bool)
- func (f *File) SetAfter(anchor, key, value string) (created bool, err error)
- func (f *File) SetBefore(anchor, key, value string) (created bool, err error)
- func (f *File) Unset(key string, del bool) (found bool)
- type Kind
- type Lookuper
- type Option
- func WithCommandRunner(run func(string) (string, error)) Option
- func WithCommandSubstitution(enabled bool) Option
- func WithEscapes(m EscapeMode) Option
- func WithLookup(fn func(name string) (string, bool)) Option
- func WithPlugin(plugins ...Plugin) Option
- func WithSaveGuard(fn func(*File) error) Option
- func WithValueTransform(fn func(key, value string) (string, error)) Option
- type Pair
- type Plugin
- type PluginInfo
- type RequiredError
- type SaveGuard
- type ValueTransformer
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrAnchorNotFound = errors.New("dotenv: anchor key not found")
ErrAnchorNotFound is returned by SetAfter and SetBefore when the anchor key has no active entry.
A dedicated error rather than a silent append: the whole point of positional insertion is WHERE the key lands, so quietly putting it at the end would defeat the call while reporting success.
Functions ¶
func Read ¶
Read is the whole-file shortcut: open, parse, expand, and hand back the values.
m, err := dotenv.Read(".env")
Equivalent to Open followed by ExpandedMap, which is what most callers want when they only need the configuration and will never write the file back.
The values are EXPANDED, because that is what a consumer sees: a value stored as "https://api.${DOMAIN}" arrives resolved. Use Open and Map instead when the file will be edited and saved — persisting an expanded value bakes the reference and silently breaks the cascade.
A missing file is an error here, unlike Open. Open exists partly to bootstrap a file that does not yet exist; Read is for consuming one that should.
It does not touch os.Environ. Nothing in this package does.
Types ¶
type Capability ¶
type Capability string
Capability names one ability a plugin was detected as having.
Reported by Plugins so a missing capability is visible. Optional interfaces fail silently — a method with a slightly wrong signature compiles, installs, and never runs — so being able to see what was actually detected is the difference between a one-line fix and an hour of debugging.
const ( CapLookup Capability = "Lookuper" CapValueTransformer Capability = "ValueTransformer" CapCommandRunner Capability = "CommandRunner" CapSaveGuard Capability = "SaveGuard" CapEntryObserver Capability = "EntryObserver" CapExpandObserver Capability = "ExpandObserver" )
type CommandRunner ¶
CommandRunner executes a $(command) substitution.
Unlike Lookuper this is a REPLACEMENT, not a chain: passing a command through two runners is meaningless, so the last one installed wins. Installing any runner enables command substitution, which is otherwise off.
type Entry ¶
type Entry struct {
// Kind classifies the entry.
Kind Kind
// Raw holds the exact physical lines, without terminators. For an untouched
// entry these render back byte-identically — this field is what makes the
// preservation invariant possible.
Raw []string
// Key is meaningful for KindPair, KindDisabledPair, and KindInherited;
// empty for every other kind.
Key string
// Value is the DECODED value for KindPair: quotes stripped, escapes
// interpreted, multi-line joined with \n.
Value string
// contains filtered or unexported fields
}
Entry is one logical unit of the file: a pair (possibly spanning several physical lines), a comment, a blank, or an unrecognised line.
func NewBlank ¶
func NewBlank() *Entry
NewBlank builds an unattached empty line, for separating sections.
func NewComment ¶
NewComment builds an unattached comment spanning one line per argument.
NewComment("----------", "DATABASE", "----------")
Each line is prefixed with "# " unless it already begins with "#", so a caller can pass either plain text or pre-decorated lines without ending up with "##".
func NewPair ¶
NewPair builds an unattached KEY=value entry.
New entries always use `=` — the canonical delimiter every dotenv dialect reads. The colon form is only ever PRESERVED from source, never authored: see Entry.prefix.
The value is rendered when the entry is attached by Append or Insert, not here: quoting is settled at construction, but the line ending is a property of the destination file, which this entry does not yet have.
type EntryObserver ¶
type EntryObserver interface {
ObserveEntry(e *Entry)
}
EntryObserver sees each entry as it is parsed.
Infallible by design, which is what lets Parse keep its no-error signature. A plugin that needs to reject a file does it at SaveGuard time, or the caller inspects the parsed File.
Observers see EVERY entry, including KindDisabledPair and KindOther. A commented-out setting is a finding for an auditor, not noise, and filtering it out would hide the most interesting case.
type EscapeMode ¶
type EscapeMode int
EscapeMode selects how backslash sequences inside double-quoted values are decoded.
The two modes are genuinely incompatible, which is why this is a choice rather than a default someone has to work around: under Compose, `\n` in a value stays two characters; under Extended it becomes a newline. Whichever a parser picks, some .env in the wild is misread — so the caller picks, based on who wrote the file.
const ( // EscapeExtended interprets \n, \r, \t, \" and \\ — what most .env // tooling outside Docker does, and what most application developers // expect. This is the default. EscapeExtended EscapeMode = iota // EscapeCompose interprets only \" and \\. Every other backslash sequence // is kept literally, which is how Docker Compose reads a .env. Use it for // files compose consumes, and for values holding Windows paths or regexes // where a backslash means itself. EscapeCompose )
type ExpandObserver ¶
type ExpandObserver interface {
ObserveExpand(key, name, resolved string)
}
ExpandObserver sees every reference resolution.
key is the entry whose value is being expanded, name is the variable referenced, and resolved is what it became. Enough to answer "which variables does this file actually use", which is what dead-key detection needs.
Infallible, for the same reason as EntryObserver.
type File ¶
type File struct {
// Path is where the file was loaded from, and where Save writes.
Path string
// contains filtered or unexported fields
}
File is a parsed .env plus the formatting facts needed to render it back byte-identically.
func Open ¶
Open reads and parses path.
A missing file is NOT an error: it loads as an empty File so a caller can bootstrap a fresh .env, and Save will create it at 0600. Callers that need to distinguish the two cases ask Existed.
func Parse ¶
Parse parses content directly, with no filesystem involved.
Useful for parsing a .env that arrived over a pipe, out of an embedded fixture, or from a secret store — and it makes the parser testable without touching disk.
func ParseReader ¶
ParseReader parses content read from r.
Prefer this over reading the bytes yourself. io.ReadAll returns a []byte and converting that to a string copies the whole thing again, on top of the doubling io.ReadAll does as its buffer grows. Copying into a strings.Builder hands its buffer to the string directly, so both costs disappear.
Measured on a ~1.4 MB file by BenchmarkParseLarge: 9.8 MB and 67 allocations against 13.0 MB and 93 for ReadAll-then-Parse. Re-run it rather than trusting these numbers — that is what the benchmark is for.
This matters once a .env carries a PEM block or a base64 certificate and runs to megabytes, which is common enough to design for.
The whole content is still held in memory, unavoidably: preserving a file byte for byte means keeping every line. This lowers the cost; it does not stream.
func (*File) Append ¶
Append adds entries to the end of the file, exactly as given.
Append is LITERAL: it places what you hand it, duplicates included. That is the opposite of Set, which upserts to guarantee a single active entry. A generator wants literal placement; an editor wants upsert. Blurring the two is how a key silently moves.
f.Append(
dotenv.NewBlank(),
dotenv.NewComment("----------", "DATABASE", "----------"),
dotenv.NewPair("DB_HOST", "localhost"),
)
func (*File) Clone ¶
Clone returns an independent copy: editing either leaves the other untouched.
Cheaper and more faithful than Parse(f.Render()) — no re-parsing, and entries keep the exact Raw bytes they were read with, including any the parser would classify differently on a second pass.
func (*File) Count ¶
Count returns how many ACTIVE pair entries exist for key.
Duplicates are worth surfacing: only the last is effective, so a caller that silently ignores the others hides a real authoring mistake.
func (*File) Disabled ¶
Disabled returns the commented-out settings in file order.
These are inactive — Get and Map ignore them — but visible, so a tool can report "DB_USER is disabled" rather than "DB_USER is missing", which are different problems with different fixes.
func (*File) Entries ¶
Entries returns the parsed entries in file order, including comments and blanks — the view a linear read of the file would give.
func (*File) Existed ¶
Existed reports whether the file was present when Open ran. False means Save will create it.
func (*File) ExpandedMap ¶
ExpandedMap is Map with every value expanded. It stops at the first RequiredError rather than returning a half-expanded map.
func (*File) Get ¶
Get returns the raw value for key.
With duplicate keys the LAST one wins, matching dotenv and Compose semantics: what Get reports is what a consumer of the file would actually see.
func (*File) GetExpanded ¶
GetExpanded is Get with references resolved against the other entries in the same file — the interpolation Docker Compose performs when IT reads the .env.
It exists for consumers that read the file THEMSELVES rather than through compose. Without it, a value authored as a reference — ADMIN_PASS=${SECRET} — reaches such a consumer as the literal string "${SECRET}" instead of the shared secret.
The error is non-nil only for a ${VAR:?message} or ${VAR?message} reference whose variable is unsatisfied; see RequiredError. Every other unresolved reference expands to "" rather than failing, matching Compose.
Get stays the right call for anything written back to disk: expansion must feed what a consumer READS, never what is persisted, or a reference would be flattened into a copy of its target and the link lost.
func (*File) Has ¶
Has reports whether an active pair exists for key. A commented-out entry does not count — it is not in effect.
func (*File) Inherited ¶
Inherited returns the names declared as inherited-from-the-environment — the Compose name-only lines (`HOME`) — in file order, deduplicated.
The names are DECLARATIONS, not values: this package never reads os.Environ, so resolving them is the caller's job. A caller that wants Compose's behaviour walks this list and applies its own source (os.Getenv, a secret store) with its own precedence — the same layering the package doc prescribes for loading in general.
func (*File) InsertAfter ¶
InsertAfter places entries immediately after the anchor key's entry.
Returns ErrAnchorNotFound and changes nothing when the anchor has no active entry. Like Append, this is literal placement — use SetAfter for upsert semantics.
func (*File) InsertBefore ¶
InsertBefore is InsertAfter, placing entries immediately before the anchor.
func (*File) Map ¶
Map returns the effective key/value view: last occurrence wins, matching Get.
Use this to hand the file to something that wants a map. Prefer Pairs when order or duplicates matter.
func (*File) Pairs ¶
Pairs returns the active pairs in file order.
Duplicates appear as-is, so the caller sees the same view a linear read would give rather than a silently de-duplicated one.
func (*File) Plugins ¶
func (f *File) Plugins() []PluginInfo
Plugins reports every installed plugin and what it was detected as.
Use it when a plugin appears to do nothing: a capability missing from this list means the method exists under a different name or signature than the interface requires.
Example ¶
f := Parse("A=1\n", WithLookup(func(string) (string, bool) { return "", false }))
for _, p := range f.Plugins() {
fmt.Println(p)
}
Output: lookup: Lookuper
func (*File) Render ¶
Render produces the file content. For untouched entries this is byte-identical to the source, which is the guarantee the whole package exists to provide.
func (*File) Restore ¶
Restore re-activates the last disabled entry for key, reporting whether one was found. It is the inverse of Unset with del=false.
The exact comment marker recorded when the entry was disabled is removed, so a line the author wrote as "#DB_USER=admin" comes back without inventing a space that was never there.
A commented-out MULTI-LINE value cannot be restored from a re-read file: each of its lines parses as a separate comment, so only the first is recognised as a disabled pair. Within one session, where Unset kept the block together, Restore reverses it completely.
func (*File) Save ¶
Save writes the rendered file atomically.
A sibling temp file is written at 0600 — never a wider window, even briefly — then chmod'd to the target mode and renamed over the destination. A rename within a directory is atomic, so a reader never observes a half-written credentials file, and a crash mid-save leaves the original intact.
The explicit chmod is required because os.WriteFile's mode argument is filtered by the process umask, so it cannot be relied on to reproduce the original permissions.
func (*File) SaveAs ¶
SaveAs writes the file to a different path, leaving f.Path unchanged.
Not mutating f is what makes emitting several variants from one source read cleanly:
f, _ := dotenv.Open(".env.staging")
f.Set("DOMAIN", "acme.io")
f.SaveAs(".env.prod")
f.Set("DOMAIN", "qa.acme.io")
f.SaveAs(".env.qa")
An EXISTING destination keeps its own permissions; a new one is created with this file's mode. Overwriting must never widen a file a user has deliberately locked down, and must never quietly loosen one it is about to fill with secrets.
func (*File) Set ¶
Set updates key to value, appending `KEY=value` when the key is absent.
Returns created=true when appended. The editing rules exist to keep diffs honest:
- only the LAST occurrence is updated, because it is the effective one
- setting the identical value is a byte-level no-op
- everything left of the value (export prefix, spacing, and the `=` or `:` delimiter the author chose) and the inline comment after it are preserved exactly — a colon-delimited pair is never rewritten to `=`
- a changed value is re-rendered with canonical quoting, and a value containing newlines becomes a double-quoted multi-line block
func (*File) SetAfter ¶
SetAfter sets key, placing a NEW entry immediately after the anchor key's entry.
An existing key is updated in place and NOT moved: relocating it would rewrite two regions of the file for a one-value change, and the author put it where it is for a reason. created reports whether an entry was added.
Returns ErrAnchorNotFound if the anchor has no active entry, leaving the file untouched, so a caller can decide between falling back to Set and treating it as a template error.
func (*File) Unset ¶
Unset deactivates key, returning whether it was found. Like Set, it targets the last occurrence.
With del=false every physical line of the entry is commented out: reversible, diff-friendly, and the documentation above it stays attached to something. With del=true the entry is removed outright.
type Kind ¶
type Kind int
Kind classifies a parsed entry. Closed set — the parser produces nothing else.
const ( // KindPair is a KEY=value entry, possibly multi-line and possibly // `export`-prefixed. KindPair Kind = iota // KindComment is a full-line comment: the first non-space character is #. KindComment // KindBlank is an empty or whitespace-only line. KindBlank // KindOther is any line the parser does not recognise. Preserved verbatim // and never touched — an unparseable line is far more likely to be // something the parser has not learned yet than something safe to discard. // // This is a DELIBERATE divergence from compose-go, which errors on an // invalid character in a key (`unexpected character "!" in variable name`) // and refuses the whole file. An editor cannot afford that: erroring would // make an otherwise-valid file unopenable — and so uneditable — because of // one stray line, and "parse, edit, save" must never be blocked by content // it was never going to touch. Preservation over rejection. The cost is // that a malformed pair is silently invisible to Get/Keys/Map rather than // loudly reported; a caller that wants strictness can walk Entries and // treat KindOther as its own error. KindOther // KindDisabledPair is a commented-out setting: "# DB_USER=admin". Key and // Value are populated, but the entry is INACTIVE — Get, Has, Count, and Map // all ignore it, exactly as a consumer of the file would. // // It is a distinct kind rather than a plain comment because the two mean // different things to anything inspecting a file: one is a setting somebody // turned off, the other is prose. Restore turns it back on. // // The parser assigns this to any comment whose body parses as a pair, so a // line the author commented out by hand is indistinguishable from one Unset // produced. The cost is a false positive on prose shaped like an // assignment — "# TODO=fix this" reads as a disabled setting, which is also // what a human skimming the file would assume. KindDisabledPair // KindInherited is a name-only line — `HOME`, or `export HOME` — with no // delimiter and no value. In the Compose env-file grammar it declares that // the variable's value comes from the process environment, and it is how a // file whitelists host variables (an AWS key, a proxy setting) without // hardcoding their values. // // This package recognises the declaration but NEVER resolves it: doing so // would read os.Environ, which this package promises not to touch. So the // entry is INACTIVE — Get, Has, Count, Keys, Pairs, and Map all ignore it, // and an unresolved ${reference} to it expands to "" like any other unset // variable. Inherited lists the declared names so a caller that wants // Compose's behaviour can apply os.Getenv (or any other source) itself — // the decision to read the environment stays with the application. KindInherited )
type Lookuper ¶
Lookuper supplies a value for a reference the file itself does not define.
It runs AFTER the file's own keys, so the file always wins — adding an environment fallback cannot silently override a value somebody wrote down. Compose behaves the same way.
Returning ok=false means "I do not have this", and the next Lookuper is tried; the first one to answer wins. An error aborts expansion rather than being treated as a miss, because a secret store that is down must not look like a variable that is unset.
A supplied value is expanded like any other, so it may itself contain references, and the cycle guard covers it.
type Option ¶
type Option func(*options)
Option customises parsing and expansion.
func WithCommandRunner ¶
WithCommandRunner overrides how $(command) is executed.
Exists so tests can exercise substitution without spawning a shell, and so an application can supply a sandboxed or allow-listed runner instead of raw `sh -c`. Implies WithCommandSubstitution(true).
Sugar for WithPlugin over a CommandRunner — a one-function hook should not require declaring a type.
func WithCommandSubstitution ¶
WithCommandSubstitution enables $(command) expansion in GetExpanded.
OFF by default, deliberately. With it on, merely READING a configuration file executes arbitrary shell — so a .env fetched from a repository, a container image, or a teammate becomes remote code execution. That is a decision the calling application must make knowingly about files it trusts, not something a parser should do silently.
When enabled, commands run through `sh -c` and a failing command expands to the empty string, matching how shells treat a failed substitution in a value.
func WithEscapes ¶
func WithEscapes(m EscapeMode) Option
WithEscapes selects the escape-sequence dialect. Defaults to EscapeExtended.
func WithLookup ¶
WithLookup supplies values for references the file does not define.
Sugar for WithPlugin over a Lookuper:
dotenv.Parse(src, dotenv.WithLookup(os.LookupEnv))
That single line is how a caller opts into Compose's environment fallback without this package ever touching os.Environ itself.
func WithPlugin ¶
WithPlugin installs a plugin.
The plugin's capabilities are detected once, here, by type assertion. A plugin implementing none is legal — it may exist only to be listed — but is usually a signature typo, which is what Plugins exists to make visible.
func WithSaveGuard ¶
WithSaveGuard refuses a write when the file fails a check.
Sugar for WithPlugin over a SaveGuard. The guard may reject, never rewrite.
func WithValueTransform ¶
WithValueTransform rewrites values after they are read.
Sugar for WithPlugin over a ValueTransformer:
dotenv.Parse(src, dotenv.WithValueTransform(func(key, v string) (string, error) {
s, ok := strings.CutPrefix(v, "encrypted:")
if !ok { return v, nil }
return decrypt(s)
}))
type Plugin ¶
type Plugin interface {
Name() string
}
Plugin is anything that can be installed with WithPlugin.
A plugin declares its abilities by implementing the capability interfaces below — Lookuper, CommandRunner, and the rest — and implements only the ones it needs. One plugin may hold several: a secret store naturally wants Lookuper for undefined references and, later, a value transformer for its own URI scheme, sharing one client and one cache between them.
Name is required rather than derived so a failure can say which plugin caused it: "dotenv: plugin \"vault\": lookup DB_PASSWORD: connection refused".
type PluginInfo ¶
type PluginInfo struct {
Name string
Capabilities []Capability
}
PluginInfo is one installed plugin and the capabilities it was recognised as having.
func (PluginInfo) String ¶
func (p PluginInfo) String() string
String renders as "vault: Lookuper, CommandRunner", or "tracer: (none)".
type RequiredError ¶
type RequiredError struct {
// Key is the referenced variable that was unset or empty.
Key string
// Message is the text after the operator. Empty when the author wrote none.
Message string
// Empty distinguishes ":?" (unset OR empty) from "?" (unset only).
Empty bool
}
RequiredError reports a ${VAR:?message} or ${VAR?message} reference whose variable was not satisfied.
These forms exist precisely to fail loudly: silently yielding "" would defeat the only reason an author writes one.
func (*RequiredError) Error ¶
func (e *RequiredError) Error() string
type SaveGuard ¶
SaveGuard inspects a file before it is written and may refuse.
It may VETO ONLY. Rewriting content on the way to disk would breach the rule the package rests on — a hook may change what a value reads as, never what gets written — and would invalidate the round-trip guarantee everything else depends on. Encrypt-on-write is a real want and is deliberately not this.
Every guard runs and the first error aborts the write, leaving the destination untouched.
type ValueTransformer ¶
ValueTransformer rewrites a value after it has been read.
Runs AFTER expansion, and its output is NOT expanded again: a decrypted secret containing ${...} stays literal, which is what a secret full of dollar signs wants. Transformers are CHAINED — each sees the previous one's output — so a value can be dereferenced by one plugin and decoded by another.
It applies to literal values too, single-quoted and backtick alike. Quoting controls interpolation, not transformation: an "encrypted:" value is very often single-quoted precisely to keep the parser out of it, and a transformer that skipped those would be useless.
An error aborts the read rather than yielding the untransformed value, because a caller must never silently receive ciphertext where it expected a secret.