Custom DSL Types
Custom DSL types turn project-specific business types into DSL types. For example, after registering a tenant ID type as tenant-id, application schemas can write:
This page answers three questions:
- How do you define a business type once?
- Which of the three entries should you use?
- How do parameters, required markers, enums, and constraints stay separate?
These entries must produce equivalent schema output. Do not define the same business type three times.
Dynamic Values
Dynamic values can still be used in pure DSL, but remember what happens: JavaScript first creates a normal string, and schema-dsl parses that final string.
If scope is 'corp', schema-dsl receives tenant-id:corp!; if it is 'tenant', schema-dsl receives tenant-id:tenant!.
If the variable is named params but it is itself a string such as 'corp', then `tenant-id:${params}!` also works. What the docs should avoid is interpolating a whole config object.
Docs may show examples such as `tenant-id:${scope}!`, but should not recommend interpolating an entire object into ${...}. If the value comes from user input, whitelist it before building the DSL string.
Define A Business Type First
You can think of an extension definition as “the rule that translates a business type into JSON Schema.” This example turns tenant-id into a string schema, and uses the corp / tenant parameter to choose the expected prefix.
On first read, focus on these five fields:
A TypeScript signature alone, such as tenantId(scope: 'tenant' | 'corp'), is not enough because TypeScript only helps while editing and is erased at runtime. The runtime still needs params to parse DSL strings, validate invalid values, apply defaults, and generate docs.
Extension Definition Fields
When you need the full contract, use this table:
Naming Rules
If literal conflicts with a built-in type, or factoryName conflicts with a built-in factory, builder method, or existing extension, registration should fail with a clear error. Silent overrides make the same DSL mean different things in different modules.
Parameter Configuration
Parameters are short values after the colon. They should stay small and serializable so they fit naturally in DSL strings:
Here corp comes from params.scope. It is not a field enum value and not a generic string constraint.
Do not force objects, functions, regular expressions, or multi-field options into DSL strings. Use s.xxx(...) factory parameters instead. This example shows the full parameter flow: both prefix and length are consumed by the final regular expression.
The result is easy to trace:
This way, users can see where each parameter is used instead of guessing what an option such as length changes.
params Fields
Each key in params is a parameter name; each value is its declaration. The same declaration parses 'tenant-id:corp!' and constrains s.tenantId('corp').
Separate two kinds of names first. Otherwise it is easy to mistake scope, min, max, and length for schema-dsl built-ins:
For example, these are all parameter names. Their meaning comes from how the extension author uses them in schema(...):
scope, min, max, and length have no built-in magic by themselves. The actual behavior comes from schema(...):
Parameter Kinds
Do not force complex values into DSL strings:
How DSL Parameters Map
It is easier to read a DSL string as three parts:
The parser should read it in this order:
- Read the trailing
!/?first. They mean required or optional; they are not parameters. - Resolve the type name, such as
tenant-id, and use it to find the extension. - If
segmentMode: 'params', parse the colon segment using theparamsdeclaration. - Parameter mode reads one short value. Ranges and comparisons use the existing constraint syntax instead of comma-splitting multiple parameters.
- Missing parameters use
default; if no default exists andrequired: true, throw. - Extra parameters, invalid enum values, invalid numbers, and unparseable booleans should produce readable errors.
Examples:
How To Write Ranges
Do not write ranges as comma-separated parameters. schema-dsl already has range syntax, and users already read number:18-65 as minimum 18, maximum 65. Custom types should reuse that rule.
Start with what users write:
For users, this simply means: minimum age 18, maximum age 65.
The extension author declares this as a constraint-style extension:
That configuration means the 18-65 segment is handled by the existing range parser, not by a custom multi-parameter parser.
age-range:18,65! is not supported today. In schema-dsl, commas mainly belong to enum: lists, such as enum:number:1,2,3!; they should not become a generic multi-parameter separator.
If a custom type really needs multiple unrelated arguments, prefer s.xxx({ ... }) or s.xxx(a, b) instead of forcing them into one compact DSL string.
DSL Syntax That Is Easy To Mix Up
Custom DSL types must not change existing DSL meaning. Use this table to separate similar-looking syntax:
Required, Optional, and Key-Level Required
! / ? are field required markers, not extension parameters:
Field Enums vs Parameter Enums
Keep these two enum concepts separate:
Each extension declares how colon segments are interpreted:
Three Concrete segmentMode Examples
segmentMode answers one question: when users write a colon segment, which rule reads the text after the colon?
segmentMode: 'none'
Use this for static business types with no parameters. Users may write the type name and required/optional markers, but no colon segment.
segmentMode: 'params'
Use this for types where the colon segment is a business argument, such as tenant-id:corp!.
segmentMode: 'constraint'
Use this when the colon segment is a comparison, range, or equality constraint. Users can reuse the core numeric constraint syntax instead of learning a second parameter syntax.
When To Set segmentMode
Avoid mixing parameters and numeric constraints in one compact string. Prefer builder methods when both are needed:
If a user writes money:usd>=0!, the implementation should produce a clear diagnostic such as: "money uses parameter mode; do not mix numeric constraints into the same colon segment. Use s('money:usd!').min(0)." Do not silently treat usd>=0 as a parameter and do not fall back to an unknown type.
Numeric Operators Are Core Constraints
These forms are not extension parameters:
Custom types should consume these constraints only when they declare segmentMode: 'constraint'. Extensions with segmentMode: 'params' should parse the colon segment as parameters instead.
Release Status And Skippable Details
When defining ordinary business types, users only need the fields above: literal, factoryName, params, schema, and the three entries.
This page describes the public usage contract for the extension system. Use the API exported by your installed package version; when reading these examples inside the repository, build locally before running them.
If you see direct String chaining, compile-time transforms, or special parser hooks in source code or older docs, treat them as compatibility or advanced capabilities, not the main entry for ordinary business types.
What Is Not A Custom Extension Entry
Custom business types do not need custom base-builder chain methods:
Existing direct String chaining and transform support remain separate compatibility and authoring tools. They should not be used to expose ordinary business types such as tenant-id.
Choosing TypeScript Hints
The three entries have different completion behavior by design:
In real projects, keep the extension registration in one local module:
Application code imports the configured s:
Low-level s.registerExtension(...) and runtime.registerExtension(...) are still useful for dynamic runtime registration, but TypeScript cannot learn a new static s.tenantId() method from one runtime call. Use the typed batch registration API, or project-specific module augmentation, when you need complete hints.
Runtime Scope
Frameworks, plugin hosts, tenants, workers, and isolated tests should register the same extension definition on a runtime instance:
runtimeS is the typed namespace returned by registration. The runtime instance is updated too, but the returned value is what carries static factory hints in TypeScript.
Runtime scope has a few important rules:
Do not require users to call uninstallStringExtensions() before they can use s('xxx'). If the goal is no global side effects, choose schema-dsl/pure or a runtime instance from the start.
Corresponding sample file
Example entry: custom-extensions.ts
Note: The sample uses the declarative parameter API. When running examples inside this repository, build locally first so dist/ matches the source.