Summary#
A few places in Osy# take a value that has to be known while the app is being compiled, not while it is running — an attribute argument, a setting in a config block, an enum member's display label, a workflow message. Those places require a constant.
A string literal is the obvious constant. So is a chain of string literals joined with +: the compiler folds
"Awaiting " + "review" into "Awaiting review" before it checks that the value is constant, exactly as C# does.
That means a long sentence can be split across lines for readability without the compiler objecting.
What is not constant is still refused, and for the real reason. A value that depends on a member, a parameter or a runtime call is not known at compile time, so it cannot go where a constant is required — no matter how it is spelled.
Signature#
[Label("Awaiting " + "review")] // folded → "Awaiting review"
[Label("Signed " + "off " + "by legal")] // any length of chain folds
[Pattern("^DOC-" + Code)] // REFUSED — `Code` is not a constant
[Pattern($"^DOC-[0-9]+$")] // REFUSED — an interpolated string is not a literalDescription#
Where a constant is required#
These are the places that read a value at compile time rather than evaluating it at run time:
- Attribute arguments —
[Label],[Pattern],[ExternalName], a constraint's optional message, and a numeric bound such as[Max(int.MaxValue)]or[Range(0, long.MaxValue)]. - Enum member labels —
[Label]and[Icon]on a member. - Config settings — the values inside a config block, and the entries of a string list.
- Workflow messages — a terminal's or a requirement's
Message. - Agent and tool descriptions — a
Descriptionon a tool or agent, which the model reads.
In each case the value is baked into the application's metadata when it is compiled. There is no later moment at which a non-constant could be evaluated, which is why the requirement exists.
Folding#
Folding is left-nested, mirroring how + associates: "a" + "b" + "c" folds only because each step folds. A chain
with one non-constant operand is not a constant at all, and returns nothing rather than a partial result — so
"Hello " + name is refused rather than quietly becoming "Hello ".
Folding happens during the compile, not in the parsed source. Tooling that reads your code — hover, go-to-definition,
selection — still sees the expression you wrote, with the + intact.
Constant members of the built-in types#
The numeric and text types carry the constant members C# gives them, written without parentheses:
int.MinValue/int.MaxValue, long.MinValue/long.MaxValue, decimal.Zero/One/MinusOne/MinValue/MaxValue,
double.MinValue/double.MaxValue and string.Empty. The .NET type names work too (Int32.MaxValue,
String.Empty). Each is folded to the value it names, so it can go anywhere a value of its type can — in a function,
in a query filter, in a component — and it can also be a member's default. Guid.Empty,
DateTime.MinValue/MaxValue, DateOnly.MinValue/MaxValue, TimeOnly.MinValue/MaxValue and
TimeSpan.Zero/MinValue/MaxValue are values too, and work in the same places.
The largest of the time values are the largest a stored value can HOLD, because a stored date or time keeps
microseconds and .NET's own maximum carries a finer tail: DateTime.MaxValue is 9999-12-31 23:59:59.999999,
TimeOnly.MaxValue is 23:59:59.999999, and TimeSpan.MaxValue / MinValue are ±10675199.02:48:05.4775800. Each
has that same value in a function, in a query filter, as a default and after a round trip through the database.
int Lowest(int[] values) {
var lowest = int.MaxValue;
foreach (var v in values) {
if (v < lowest) { lowest = v; }
}
return lowest;
}
string Initials(string name) {
return name.Length == 0 ? string.Empty : name.Substring(0, 1);
}bool IsOpenEnded(DateOnly until) {
return until == DateOnly.MaxValue;
}
TimeSpan NoLimit() {
return TimeSpan.MaxValue;
}A numeric constant member is a constant ATTRIBUTE argument too, so a bound can say what it means rather than spell out its digits:
entity Quota {
[Range(0, long.MaxValue)] long Bytes;
[Max(int.MaxValue)] int Files;
}A mistyped one is refused with its real name offered — int.Max suggests MaxValue.
Interpolated strings are deliberately not constants#
$"…" is refused where a constant is required, even when it happens to contain no holes. The $ prefix declares an
intent to interpolate, and a place that needs a compile-time value should say so plainly rather than accept a form
whose purpose is to be computed. Write a plain literal, or a + chain of them.
Examples#
A long label split across two lines, and a three-part chain:
enum ReviewState {
[Label("Awaiting " + "review")] Pending,
[Label("Signed " + "off " + "by legal")] Approved,
}An attribute whose pattern and whose failure message are both split:
entity Doc {
[Pattern("^DOC-" + "[0-9]+$", "use " + "DOC-1234")] string Code;
ReviewState State;
}Neither of these is constant, and both are refused:
[Pattern("^DOC-" + Code)] // depends on a member — not known at compile time
[Pattern($"^DOC-[0-9]+$")] // an interpolated string is not a literalSee also#
- Optional and required members — how a member's type spelling decides whether a value must be supplied
- constraints — the attributes that take a pattern and a message
- enum — where member labels are declared