Skip to content

Architecture

Dot's architecture is about keeping the implementation simple, direct, and small. That was deceptively challenging.

That simplicity is the foundation of dot's performance and bundle size. The less machinery the runtime carries, the less work there is to execute, optimize, allocate, and ship. Dot avoids heavy abstraction layers, large type hierarchies, generic validation pipelines, and framework-style extension machinery.

Profiling and bundle-size work refined the details, but the main idea is simpler: dot keeps validation boring enough that the common path stays fast.

Tree shaking is useful, but it is not a substitute for architecture. It can remove unused exports, but it cannot make a bloated model simple. If the core design has too many concepts, too many layers, or too much machinery per operation, the API and runtime still carry that weight.

Origin

Dot was designed to be so small and fast that you forget it's there.

Dot wasn’t built to compete. It was built to be practical. SajaJS needed boundary validation that was small enough for Lambda, fast enough to disappear, and typed enough to use comfortably. Existing validators were wider than the job. Dot started as infrastructure for SajaJS invoke, then became useful enough to stand alone.

Dot is small because it has less code, not less capability. That single property compounds: fewer moving parts, easier maintenance, smaller bundles, and less runtime work.

Dot’s size is not a packaging trick. It is an architectural choice.

Small Runtime Model

Dot has a fluent API, but the runtime model is small. dot.string() creates a DotString, dot.object() creates a DotObject, and every schema implements the same internal _val(input, ctx) contract.

There is no separate schema compiler, visitor system, middleware chain, type hierarchy, or generic effect interpreter. Most schema behavior lives directly in the class that validates that value.

TypeScript carries much of the expressive weight. For example, DotInt, DotNumber, and DotBigInt are type-level names over the same runtime class, DotNumeric. The runtime has fewer concepts than the public type surface.

Cheap Construction

Fluent calls clone small schema objects and set fields. A call like .min(1) or .optional() does not build a large intermediate graph or trigger a compilation phase.

That matters for inline validation. The benchmarks include a mode where schema construction and validation happen together, and dot is designed to keep that path practical. Hoisting schemas is still useful, but the architecture does not depend on it.

Direct Validation

Validation is recursive and direct. Containers call child _val() methods, and each child returns a numeric result code: failed, succeeded, or mutated.

Only the public validate() method turns failure into a thrown DotError. Inside the recursive path, validators update a shared context and return result codes. This keeps exception control flow and extra wrapper objects out of the main validation loop.

Feature Cost Is Opt-In

Optional behavior is guarded by small flags. Plain schemas do not pay for default, optional, nullable, refine, cast, or constraint work unless those features are enabled.

String validation shows the idea clearly. An unconstrained, non-mutating string schema can validate with a simple typeof check. More complex paths exist, but they are not part of the default path.

Copy-On-Write Semantics

Dot separates validation from mutation. If validation does not change a value, validate() returns the original reference.

Containers preserve that rule recursively. Arrays do not copy until the first mutated element. Objects delay result allocation when possible and only rebuild when a child changes or an end hook needs a rebuilt view.

This is part of dot's memory profile. Boundary validation often checks data once and passes it forward unchanged, so unchanged data should not be copied defensively.

Tight Container Loops

Objects, arrays, and records validate with straightforward loops. The code is intentionally local: required-field checks, child validation, issue collection, path handling, and result selection happen in the same loop.

That style is less abstract, but it avoids helper calls and bookkeeping structures in the paths that scale with schema size. Large schemas spend most of their time here.

Shared Context

A validation call creates one context object. Recursive validators reuse it for the current result, error, path, and key.

Containers push and pop path segments as they recurse. Leaf validators use ctx.key so failures can append a field or index without constantly rebuilding path arrays. Issues and errors are allocated only when validation fails.

Narrow Core

The main dot entry stays focused on built-in validation. Extension types live behind @sajajs/dot/ext, and common regex schemas live behind @sajajs/dot/*.

That keeps optional surfaces out of the core bundle. Projects that need custom types or common string formats can opt into them directly.

Dot schemas are immutable, so prebuilt schemas can be safely extended:

ts
import {dotEmail} from '@sajajs/dot/email'

const companyEmail = dotEmail.endsWith('.company.com')
// dotEmail remains unchanged

Additional Notes

The V8 work came from profiling and deopt analysis, not guesswork. Many attempted changes failed and were reverted. Bundle size was treated as a hard constraint too, so some runtime wins were abandoned or trimmed when they added too much code. In general, V8’s optimizing tiers reward local, predictable code with stable receiver shapes and no try / catch on the hot path.

Related schema types are collapsed into shared runtime classes, but this is not a DRY decision. DotSimple handles any, unknown, and boolean; DotNumeric handles int, number, and bigint. This reduces the number of receiver shapes V8 sees at hot _val() call sites. Fewer receiver shapes help inline caches stay monomorphic, or at least less polymorphic, by keeping the runtime class set small.

Dot kind values are one-character tags. They are used for runtime dispatch and union filtering, and they also reduce repeated string cost in the bundle.

Some low-level choices measured better even when they look less obvious in source. DotString uses substring() for startsWith and endsWith checks; and several branches are collapsed where that measured better.

Custom constraint messages are stored compactly. Some classes use a message map instead of adding a dedicated private field for every possible message, and there are no built-in issue messages; dot issues are structural.

Internal methods and members that remain public at runtime are kept short: _c(), _val(), _hdef, and similar names. These names appear across classes and cannot all be erased like local variables. Dot keeps them short enough to reduce gzip size.

Private fields are used in many places, but private methods are avoided because they caused Rollup to retain extra emitted scaffolding. Internal helper methods use short public names like _c() rather than #clone(). These are always marked @internal so they are stripped from the public TypeScript API.

Tradeoffs

The challenge with Dot was size and performance. Abstract too much to reduce bundle size, and runtime performance suffers. Cache too much during construction, and inline schemas suffer. The domain was straightforward; the tradeoffs were not.

Dot accepts some repetition to keep validation direct. Many _val() methods look similar because abstracting the scaffolding added machinery to hot paths. Dot type classes used to share a base class, but it added close to 40% construction overhead, so it was removed; the gzip cost was roughly 6%.

Some validators include convenience methods like length(), trim(), toLowerCase(), etc. Dot avoids these because their behavior can already be expressed with primitive methods like min() / max(), prepare(), or refine(). The exclusion removes weight without removing capability.

The result is deliberately boring code: few runtime concepts, few moving parts, and constraints stored next to the validators that use them. The profiling optimizations refine that design, but they do not replace it.