Designing with Types: Constrained Strings
How to use Effect Schema to normalize and constrain strings when they enter the domain, so formatting and length rules travel with the type.

In the wrapper types article, we made EmailAddress, ZipCode, and StateCode distinct. The compiler could stop us from passing a zip code to a function that expects an email address.
That solved one problem. It did not tell us which values each string may contain.
Consider the name in our Contact model:
class PersonalName extends Schema.Class<PersonalName>("PersonalName")({
firstName: Schema.String,
lastName: Schema.String,
}) {}
Both fields accept an empty string. They also accept newlines, surrounding whitespace, and enough text to overflow any sensible database column. string is accurate at the JavaScript level, but it says little about the value our application can handle.
The constraints hiding inside a string
Suppose our system stores first names in a column with a limit of 50 characters. We also want names to be non-empty and kept on one line. Those rules raise a few questions:
- Should surrounding whitespace be accepted?
- What should happen to tabs and line breaks?
- When should the length check run?
- Which part of the application rejects an invalid value?
The last question matters most. A database adapter is too late:
function saveName(name: PersonalName) {
const firstName = name.firstName.slice(0, 50)
// Write firstName to the database.
}
This function silently changes somebody’s name. It also makes the database adapter responsible for a domain decision. Throwing an exception instead avoids the data loss, but the invalid value has still travelled through the application.
I would rather reject it when it first enters the domain. Every function after that boundary can then trust the type.
Normalize before checking
Validation and normalization are different jobs. Validation answers whether a value is allowed. Normalization chooses one representation for equivalent inputs.
For this example, our policy is to replace each run of whitespace with one regular space, then trim both ends:
import { Schema, SchemaGetter } from "effect"
const SingleLineString = Schema.String.pipe(
Schema.decode({
decode: SchemaGetter.transform((value) =>
value.replace(/\s+/g, " ").trim(),
),
encode: SchemaGetter.passthrough(),
}),
)
Schema.decode describes a transformation whose input and output are both strings. Decoding " John\n Smith " produces "John Smith". Encoding passes the normalized value through unchanged.
Effect also provides Schema.Trim when trimming the ends is enough. We need a custom transformation because this policy handles whitespace inside the value too.
The order is deliberate. We normalize first, then validate. Otherwise, a string containing only spaces could pass a non-empty check, and surrounding spaces would count against the length limit.
This is one application’s policy, not a universal rule for human names. In particular, a “letters only” regular expression would reject many real names. A type should encode rules the domain can defend, not guesses that happen to fit the first sample data.
Reuse the policy, keep the meaning
We can build a small function that adds the common checks and a distinct brand:
function constrainedSingleLine<const Name extends string>(
identifier: Name,
maxLength: number,
) {
return SingleLineString.check(
Schema.isNonEmpty(),
Schema.isMaxLength(maxLength),
).pipe(Schema.brand(identifier))
}
Schema.isNonEmpty and Schema.isMaxLength run after normalization. Schema.brand gives the accepted value its domain identity. The brand itself adds no runtime validation, so the checks must come first.
Now the name fields can share a normalization policy without becoming interchangeable:
const FirstName = constrainedSingleLine("FirstName", 50)
type FirstName = typeof FirstName.Type
const LastName = constrainedSingleLine("LastName", 100)
type LastName = typeof LastName.Type
class PersonalName extends Schema.Class<PersonalName>("PersonalName")({
firstName: FirstName,
lastName: LastName,
}) {}
The maximum lengths are physical constraints, perhaps inherited from a database or a message format. That does not make them irrelevant to the model. If a browser, API, queue consumer, and database disagree about the limit, one of them will eventually reject or alter the value. Putting the rule in the schema gives them a definition they can share.
Decode at the boundary
We can decode the whole name in one call:
import { Result, Schema } from "effect"
const decodePersonalName = Schema.decodeUnknownResult(PersonalName)
const result = decodePersonalName({
firstName: " Ada ",
lastName: " Lovelace\n",
})
if (Result.isFailure(result)) {
result.failure // Schema.SchemaError
} else {
result.success.firstName // "Ada"
result.success.lastName // "Lovelace"
}
The decoder accepts unknown, which is what data from JSON, a form, or another service really is. A successful result contains a PersonalName whose fields are normalized and branded. A failure retains Effect’s structured schema error instead of reducing it to undefined or a boolean.
This also rejects values after normalization:
decodePersonalName({
firstName: " \n\t ",
lastName: "Lovelace",
})
decodePersonalName({
firstName: "A".repeat(51),
lastName: "Lovelace",
})
The first input becomes empty. The second remains longer than the FirstName limit. Neither can enter the domain as a PersonalName.
Operations can break the guarantee
A constrained value stays safe only while an operation preserves its rules. String concatenation does not know anything about our maximum lengths:
const combined = `${name.firstName} ${name.lastName}`
// combined is a string, not a FullName.
That loss of the brand is correct. A valid first name followed by a valid last name can still exceed the limit for a full name.
We have to decide what FullName means. Here it is a single-line string with a limit of 100 characters:
const FullName = constrainedSingleLine("FullName", 100)
type FullName = typeof FullName.Type
const decodeFullName = Schema.decodeUnknownResult(FullName)
function fullName(name: PersonalName) {
return decodeFullName(`${name.firstName} ${name.lastName}`)
}
The return type includes the possibility of a SchemaError. Callers cannot pretend every pair of valid names produces a valid FullName.
We could set the full-name limit to 151, the two field limits plus one space. We could also return an unconstrained string if downstream code has no limit. What we should not do is quietly cut the name at character 100. If truncation is a real requirement, it deserves an explicit function and a name that admits what it does.
Comparing different branded strings
FirstName and LastName are deliberately incompatible, even though both contain strings. Most of the time, comparing them is probably a mistake. When the domain does call for comparison by raw text, Effect lets us make that choice visible:
import { Equivalence } from "effect"
const sameText = Equivalence.String(
personalName.firstName,
personalName.lastName,
)
The brands have no runtime wrapper. Both arguments are still JavaScript strings, and Equivalence.String compares them with strict equality. Using it states that we intend to ignore their different domain meanings for this operation.
What does length mean?
Schema.isMaxLength uses JavaScript’s String.length. It counts UTF-16 code units, not bytes or characters as a person sees them. Some Unicode characters therefore count as two units, while a database may use another definition entirely.
This is not a reason to abandon the constraint. It is a reason to write down what the limit measures. If the storage system limits bytes, or the product requirement limits grapheme clusters, Schema.isMaxLength is not the right check on its own. The schema should match the system that enforces the limit.
When the extra type is worth it
Not every temporary string needs a brand and a schema. A search query used inside one request may gain little from either. A value stored for years, copied between services, or printed on customer documents is different. Its constraints will be enforced somewhere, whether the model admits them or not.
Constrained strings move that enforcement to the first trustworthy boundary. Branding tells TypeScript which domain concept a value represents. The schema does the runtime work by normalizing input and rejecting values outside the limits.
The annoying part is that creation and composition become more explicit. That friction is useful. It exposes decisions that would otherwise hide in a database adapter, a UI component, or an emergency production fix.
In the next article, we’ll apply the same approach to numbers and dates, where arithmetic and parsing can also create values outside the domain rules.