Designing with Types: Non-String Types
How to constrain numbers, timestamps, and units with Effect so arithmetic and date parsing cannot quietly violate domain rules.

In the previous article, we stopped treating every piece of text as an unconstrained string. Numbers and dates deserve the same suspicion.
A shopping cart quantity is not any JavaScript number. A database timestamp is not any string that Date.parse happens to accept. Once these values enter the domain, their types should state the limits that the rest of the code relies on.
We already used brands to keep UserId and ProductId apart in the wrapper types article. This time, the interesting problem is not giving numbers different names. It is keeping them valid after we operate on them.
A number can represent too much
Here is a small shopping cart with a familiar bug:
let quantity = 1
function increment() {
quantity += 1
}
function decrement() {
quantity -= 1
}
Click decrement twice and the cart contains -1 items. The type accepts that state because number also accepts fractions, NaN, infinities, and integers larger than JavaScript can represent safely.
Suppose the cart allows between 1 and 99 units of one product. We can put that rule into a schema:
import { Schema } from "effect"
const ShoppingCartQuantity = Schema.Int.check(
Schema.isBetween({ minimum: 1, maximum: 99 }),
).pipe(Schema.brand("ShoppingCartQuantity"))
type ShoppingCartQuantity = typeof ShoppingCartQuantity.Type
Schema.Int uses Number.isSafeInteger. It rejects fractions, NaN, infinities, and integers outside JavaScript’s safe range. Schema.isBetween then applies the cart’s inclusive limits.
The brand keeps this number separate from other constrained integers. A Percentage between 1 and 99 would still be the wrong value to pass as a cart quantity.
Create quantities at the boundary
As with constrained strings, unknown input should go through the schema:
const decodeShoppingCartQuantity =
Schema.decodeUnknownResult(ShoppingCartQuantity)
decodeShoppingCartQuantity(3) // Success(3)
decodeShoppingCartQuantity(0) // Failure
decodeShoppingCartQuantity(100) // Failure
decodeShoppingCartQuantity(1.5) // Failure
For a constant inside the program, the schema’s make constructor is concise:
const initialQuantity = ShoppingCartQuantity.make(1)
make throws when its input is invalid, so I reserve it for values controlled by the program. Form fields, JSON, and database results need a decoder that returns the failure.
Effect also has Schema.Natural for non-negative safe integers. It is useful for values where zero is valid, such as stock on hand. Our cart starts at one, so an explicit range says more.
Arithmetic removes the guarantee
Adding one to a ShoppingCartQuantity produces a plain number. TypeScript is right to remove the brand because the result might be 100.
The operation must check its result:
function increment(quantity: ShoppingCartQuantity) {
return decodeShoppingCartQuantity(quantity + 1)
}
function decrement(quantity: ShoppingCartQuantity) {
return decodeShoppingCartQuantity(quantity - 1)
}
Incrementing 98 succeeds with 99. Incrementing 99 fails. Decrementing 1 fails instead of creating an impossible quantity.
That failure forces the caller to choose what the product should do. A UI might disable a button. An API might reject the command. Silently clamping every result to the nearest valid number would hide that decision inside a helper.
The same rule applies to multiplication, division, and aggregation. A valid input does not imply a valid result.
Dates need a meaning and a range
JavaScript’s Date has caused enough production bugs to deserve caution. It represents an instant, but its parsing and local-time methods make it easy to blur the difference between a UTC timestamp, a local time, and a calendar date.
Effect’s DateTime types make that distinction clearer. For an audit timestamp, we can decode a string directly into DateTime.Utc:
import { DateTime, Schema } from "effect"
const minimumSupportedTimestamp = DateTime.makeUnsafe(
"1980-01-01T00:00:00.000Z",
)
const maximumSupportedTimestamp = DateTime.makeUnsafe(
"2038-01-01T00:00:00.000Z",
)
const isSupportedTimestamp = Schema.makeIsBetween({
order: DateTime.Order,
})
const SupportedTimestamp = Schema.DateTimeUtcFromString.check(
isSupportedTimestamp({
minimum: minimumSupportedTimestamp,
maximum: maximumSupportedTimestamp,
}),
).pipe(Schema.brand("SupportedTimestamp"))
type SupportedTimestamp = typeof SupportedTimestamp.Type
Schema.makeIsBetween builds a range check for any type with an Order. DateTime.Order compares timestamps by their epoch milliseconds.
The two calls to makeUnsafe create hard-coded constants. If either constant is malformed, that is a programming error and startup should fail. External values still use safe decoding.
Parse and normalize once
Schema.DateTimeUtcFromString parses a string and normalizes it to UTC. It also encodes the value as an ISO 8601 UTC string:
const decodeSupportedTimestamp = Schema.decodeUnknownResult(SupportedTimestamp)
const timestamp = decodeSupportedTimestamp("2026-08-28T12:30:00+02:00")
The decoded time is 2026-08-28T10:30:00.000Z. Inputs before 1980 or after the start of 2038 fail at the boundary, before they reach a system that cannot store them.
Once decoding succeeds, a function can ask for SupportedTimestamp rather than accepting a raw string:
function recordAuditEvent(timestamp: SupportedTimestamp) {
// The timestamp is UTC and inside the supported range.
}
This type is for instants. A birthday, billing day, or public holiday is a calendar date, not midnight UTC. Those concepts need a date-only representation. Renaming a timestamp to Birthday does not fix the mismatch.
When a civil time zone matters, Effect also has DateTime.Zoned. Choosing Utc or Zoned is part of the domain decision, not a formatting detail for the UI.
Seconds are not milliseconds
F# has units of measure, which let the compiler track dimensions through arithmetic. TypeScript does not have an equivalent feature. Brands can still stop us from passing seconds to an API that expects milliseconds:
const Seconds = Schema.Natural.pipe(Schema.brand("Seconds"))
type Seconds = typeof Seconds.Type
const Milliseconds = Schema.Natural.pipe(Schema.brand("Milliseconds"))
type Milliseconds = typeof Milliseconds.Type
function wait(delay: Milliseconds) {
// Pass delay to an API that expects milliseconds.
}
const timeout = Seconds.make(5)
wait(timeout) // Type error
The conversion should be explicit:
const decodeMilliseconds = Schema.decodeUnknownResult(Milliseconds)
function toMilliseconds(seconds: Seconds) {
return decodeMilliseconds(seconds * 1_000)
}
Multiplication removes the Seconds brand and may exceed the safe-integer range. Decoding the result catches both issues before we call wait.
This is weaker than F# units of measure. TypeScript will not derive a new unit when numbers are multiplied or divided. If a domain performs serious dimensional arithmetic, use wrapper objects or a library built for quantities. Brands are still useful at API boundaries, where confusing five seconds with five milliseconds is enough to ruin a timeout.
Keep operations inside the model
A schema validates a value when code creates it. Functions such as increment must apply the same rules after changing it.
For strings, concatenation forced us to validate FullName again. For numbers, arithmetic forces the same step. Date parsing adds another concern because it must establish what kind of time the value represents before checking its range.
Raw numbers and date strings will always exist at system boundaries. Decode them there. After that, domain functions should accept constrained types and return results that prove the constraints still hold.
In the final article, we’ll rebuild the original Contact model and look honestly at what the extra types buy us, what they cost, and what they still cannot guarantee.