Building a DSL in JavaScript Without a Build Step

Every codebase eventually grows its own informal language. You start with functions like createUser(name, email, role), and before long the signature is eight arguments deep and nobody remembers what the fifth one does.

The standard fixes — options objects, builder classes, method chaining — all work, but they require you to design the API up front. Change your domain, change your schema. That rigidity is the real cost.

dsl-framework takes a different approach: instead of defining an API, you define a language, and let the execution follow from the syntax.


The Core Idea

A traditional fluent API executes as it chains:

new QueryBuilder()
  .select('name')    // executes: adds 'name' to SELECT
  .from('users')     // executes: sets table to 'users'
  .where('active')   // executes: adds WHERE clause
  .run()

dsl-framework separates recording from executing. The chain builds up a data structure — an abstract syntax tree — and nothing happens until you invoke it with ():

const factory = dslFramework();

const result = factory()
  .select('name')
  .from('users')
  .where('active')
  ();   // ← this is the trigger

That closing () collapses the chain into a plain data structure you can inspect, transform, or pass to a handler.


What the Data Looks Like

Under the hood, each dotted segment becomes a chunk in returnArrayChunks:

const result = factory().Hello.world('!').data;

console.log(result.returnArrayChunks);
// [['Hello'], ['world', '!']]

console.log(result.returnArray());
// ['Hello', 'world', '!']

Arguments attach to the command that precedes them. The structure is simple enough to traverse by hand, but the framework ships with utilities so you rarely have to.


Responding to Commands

The real power shows up when you pass a callback. The callback receives the parsed data and decides what to do with it:

const greet = factory((err, data) => {
  const loud = data.command.has('loud');
  const message = data.returnArray().join(' ');
  return loud ? message.toUpperCase() : message;
});

await greet.hello.world();        // 'hello world'
await greet.hello.world.loud();   // 'HELLO WORLD'

Notice that loud isn't a function you defined anywhere. It's just a word in the chain. The callback checks whether it appeared and branches accordingly. Adding new "keywords" to your language costs zero infrastructure.


Validating Structure

The command parser ships with logical operators for checking what's in a chain:

factory((err, data) => {
  if (!data.command.hasAnd('Task', 'Deadline')) {
    return 'incomplete';
  }

  const taskName = data.arguments('Task', 'firstArgument');
  const deadline = data.arguments('Deadline', 'firstArgument');

  return `${taskName} due ${deadline}`;
})
  .Task('Write tests')
  .Deadline('2026-04-01')
  ();

hasAnd, hasOr, hasXor — these read almost like English, which is the point. The DSL you write ends up looking like the domain it describes.


Lazy Execution as a Feature

Because the chain doesn't execute until (), you can pass a pending command between modules:

// Start a command in one place
const pending = factory().Task('Code review');

// Hand it to another module that adds context
function addPriority(cmd) {
  return cmd.priority('high');
}

// Execute later
addPriority(pending)();

This turns your DSL into a first-class value. You're not calling functions — you're constructing a command object through syntax, and shipping it wherever it needs to go.


Where It Goes From Here

dsl-framework is the foundation for two other tools in this portfolio:

The install is one line:

npm install dsl-framework

View on GitHub · View on NPM