DEV Community

Cover image for The Illusion of Language: What Directives Really Are
Viktor Lázár
Viktor Lázár

Posted on

The Illusion of Language: What Directives Really Are

Preface & Introduction — Why This Post Exists

This post is not a rebuttal.

It’s a reflection inspired by the great “Directives and the Platform Boundary” article by Tanner Linsley. I genuinely enjoyed the piece and agree with many of its points — especially around ownership, provenance, and the value of explicit APIs.
What I wanted to add is a slightly different lens: one that comes from building directive-driven tooling and from having lived through a very similar chapter in programming history, long before JavaScript had 'use client' or 'use server'.

Over the past year, I’ve watched countless developers treat directives in JavaScript as if they were actual language features — something built into the JavaScript spec or the runtime environment. And to be fair, at a glance, it does look that way. A string at the top of a file that magically changes how the code behaves feels authoritative.

If you haven’t been following the evolution of React Server Components and frameworks like Next.js: modern React apps now use file-level directives such as 'use client' and 'use server' to control where code runs. They look like simple string literals at the top of a file, but they decide whether a component executes on the server or the client — shaping bundling, rendering and data-flow. That’s where the confusion begins.

But here’s the tension:

Directives look like language features.
Directives feel like language features.
Yet directives are not language features.

They are tooling-level signals — consumed by bundlers, compilers, and build pipelines.

This misunderstanding is not a new phenomenon.
We’ve been here before.

A Personal Flashback

The first time I saw a #pragma once in a C++ codebase as a young developer, I thought:
“Wow, the language has a keyword for this! Why doesn’t JavaScript?”

Except… it wasn’t a language keyword.
It was a compiler instruction. A directive. A hint to the preprocessor, not to the language itself.

Just like 'use strict' wasn’t “JavaScript syntax” at first — and just like 'use client' isn’t “JavaScript syntax” today.

This post explores that parallel. Because understanding the C/C++ preprocessor era is a surprisingly powerful way to understand modern JS directives — why they confuse people, and what we could learn from the past to teach them better.

Why Do Directives Feel Like Language Features?

If you show a newcomer the following code:

'use client';

export function Button() {
  return <button>Click</button>;
}
Enter fullscreen mode Exit fullscreen mode

and ask them what 'use client' is, most will confidently answer:

“It’s part of JavaScript.”

This isn’t ignorance. It’s pattern recognition.

Throughout their entire programming life, file-level “magic statements” have almost always been language semantics, not tooling semantics:

  • "use strict"
  • "use asm"

They look like syntax.
They live at the top of the file.
They change behavior.

So the brain does the obvious thing: it classifies them as language.

The TanStack article captures part of this well when it says:

“A directive at the top of a file looks authoritative. It gives the impression of being a language-level truth, not a framework hint.”

This is the crux of the confusion.

The Hidden Mechanism Makes It Worse

Directives blur boundaries because the code that reacts to them is:

  • invisible
  • non-local
  • not traceable through imports

If I write:

import { client } from 'somewhere';
Enter fullscreen mode Exit fullscreen mode

→ I can follow the import. I see who owns it. I can version it. I know what documentation to search for.

But with directives:

  • there is no import
  • no namespace
  • no ownership reference
  • no callsite to inspect

The behavior “comes from nowhere”.

Which is exactly why they feel like language.

Add Build Tools… and the Illusion Solidifies

Unlike JavaScript keywords, directives do nothing by themselves.

A runtime engine won’t throw if you mistype 'use clinet'.
A bundler or transform plugin decides what to do with it. Some ignore it, some error, some treat it as a string literal.

This leads to the second major confusion:

“If it requires a bundler to work, isn’t it part of the ecosystem platform, not the language?”

From the developer’s perspective, if code changes behavior without explicit imports, the mind defaults to:

✅ language feature
❌ library feature

And that is the trap.

Because what we call “platform” today is actually a stack of tools — not a coherent language surface. Exactly like in the C/C++ era.

A Look Back: C/C++ Macros and the Preprocessor

Long before modern JavaScript build pipelines, the C and C++ world lived through a very similar confusion. And it all started with a layer that sat before compilation: the preprocessor.

To understand today’s directives, we need to briefly revisit three pillars of that era — because they map surprisingly well to what we see today.

The C Preprocessor Was Not the Language — But It Felt Like It

Consider this:

#define PI 3.14
Enter fullscreen mode Exit fullscreen mode

Many beginners encountering this for the first time assume:
“Oh, C has a special language keyword for defining constants.”

Except… #define is not part of the C language grammar.
It’s an instruction to a separate tool that runs before the compiler.

It performs text substitution — not type checking, not syntax analysis, not semantic validation.

Yet, because it lived inside .c/.h files and looked like “code”, generations of developers perceived it as the language.

Sound familiar?

A directive in JS behaves the same way:

'use server';
Enter fullscreen mode Exit fullscreen mode

The JavaScript engine doesn’t know what that means.
The bundler decides what to rewrite before execution.

Both are pre-language phases that masquerade as language.

Pragmas: The Original “Framework Directives”

If macros were like today’s codegen utilities, then #pragma was the closest ancestor to modern directives:

#pragma once
Enter fullscreen mode Exit fullscreen mode

This line is not part of the C++ language spec.
It’s a compiler-specific “hint” — an instruction that affects how the build system treats this file.

  • No import
  • No namespace or provenance
  • No indication of ownership

Different compilers supported different pragmas. Some ignored them. Some produced warnings. Some behaved differently.

Replace “compiler” with “bundler” and you get 2025.

For example:

  • Next.js reacts to 'use client'
  • Vite might ignore it
  • A custom RSC bundler might interpret it differently

It’s the same fragmentation pattern seen 30 years ago.

Include Guards: The First “Invisible Build-Time Behavior”

Before #pragma once became common, C/C++ used include guards to prevent double inclusion:

#ifndef MY_HEADER_H
#define MY_HEADER_H

// header contents

#endif
Enter fullscreen mode Exit fullscreen mode

This pattern:

  • altered program behavior
  • existed only for tooling
  • had no runtime meaning
  • required knowledge of the build model, not just the language

This is important:

C/C++ forced developers to learn that “code you write in a file” and “the language itself” are not the same thing.

This was a painful but transformative lesson.

We are now at the same educational moment in JavaScript.

Multi-Stage Compilation: A Familiar Pipeline

C/C++ had distinct layers:

Preprocessor → Compiler → Linker → Executable
Enter fullscreen mode Exit fullscreen mode

Modern JS tooling has the same separation, just with cooler names:

Directive Scanner / Loader → AST Transforms → Bundler → Output Chunks
Enter fullscreen mode Exit fullscreen mode

And just like beginners blamed “C++ the language” for preprocessor quirks, today developers blame:

  • “JavaScript”
  • “React”
  • “the platform”

…for behaviors that actually originate from build tooling.

History is repeating itself — almost line for line.

Modern Directives Through the Lens of the Preprocessor Era

Once you see the C/C++ parallels, modern JavaScript directives suddenly make a lot more sense. They are not an evolution of JavaScript syntax — they are an evolution of compiler hints.

Let’s make the mapping explicit:

Preprocessor Era Concept Modern JS Equivalent
#pragma 'use client', 'use server'
#define macros code transforms / auto-generated wrappers
#include guards module boundary / hydration boundaries
compiler-specific behavior bundler-specific behavior
multi-stage builds loader → transform → bundle pipeline

Just like in the 90s, the surface looks deceptively simple — but the real action happens underneath.

Directives Don’t Execute — They Instruct a Tool

A key property of directives is this:

They don’t do anything themselves — they only change how something else behaves.

Here’s a breakdown of what typically happens in a directive-driven pipeline (simplified, but accurate enough):

Read file → Check for directives → Decide environment / boundary →
Run transforms → Generate client/server bundles → Output
Enter fullscreen mode Exit fullscreen mode

Let’s compare that to the C pipeline:

Read file → Preprocessor expands macros + handles pragmas →
Compile → Link → Output
Enter fullscreen mode Exit fullscreen mode

They are structurally identical concepts, just applied to different languages and eras.

Why the Illusion of a Language Feature Persists

Three psychological factors contribute to the confusion:

  1. They look like syntax
    Both #pragma once and 'use client' feel like reserved keywords.

  2. They live at the top of the file
    Anything that shapes the entire file is often assumed to be language.

  3. They act globally and implicitly
    They don’t require instantiation or import.

In other words, directives successfully exploit a linguistic illusion.
They act like a programming language — while not being one.

But Modern Directives Go Further Than C Pragmas

Here’s where things get more interesting:

C pragmas only affected compilation behavior.
Modern directives often affect execution model, code placement, bundling, and runtime boundaries.

For example, 'use server' might:

  • move code into a server-only chunk
  • replace calls with RPC stubs
  • add serialization wrappers
  • enforce data-flow constraints

This is already beyond what the C preprocessor did.

It’s closer to a macro system + compiler pass.

Which is exactly why understanding the distinction matters:

If something affects runtime execution and code placement, we should not treat it as “just a string at the top of the file”.

And yet, ironically, most misconceptions arise because that is exactly how it appears.

Build Tools Are the Real Interpreter of Directives

Just like different C compilers treated pragmas differently, modern JS tooling varies, too:

  • Next.js interprets 'use client' one way
  • Vite + RSC implementations another
  • Third-party bundlers a third way

If tomorrow another framework introduced:

'use streaming-server';
Enter fullscreen mode Exit fullscreen mode

JavaScript engines wouldn’t care.
Tools would decide what it means.

And that is the mental shift we need developers to make:

Directive ≠ language
Directive = build instruction

They live in userland — not in the spec.

Before we move on, it’s worth pausing for a moment. The parallels with C and C++ are useful for understanding the shape of directives, but they can still feel abstract until you’ve seen them in practice. To bring the idea into clearer focus, let’s look at a concrete example from today’s ecosystem — one that makes the theory a bit more tangible, and shows how these compiler hints manifest in real, modern code.

📦 When Inline Server Functions Reveal the Need for Directives

I once ran into a case that perfectly exposed why some server behaviors cannot be left to runtime.

Consider an inline server function defined inside a Server Component, capturing variables from its local scope:

export default function Dashboard() {
  const rate = 0.27;

  async function save(value) {
    'use server';
    return value * rate; // ← captures "rate" from the component scope
  }

  return <button onClick={() => save(10)}>Save</button>;
}

At first glance, this feels like a normal function call. But for this to work, the bundler must do something subtle and non-negotiable:

  • detect that save is a server function,
  • hoist it into a server-only module,
  • perform AST-level closure analysis to capture rate,
  • and generate a stable, directly callable reference for the client — not a runtime-constructed wrapper.

A runtime helper cannot solve this.
By the time the code runs, the closure is gone and the intent is already lost.
The function must be transformed before the program exists, so that the client can call it as a direct function, not a proxy we stitched together too late.

This is why directives fit this space so well: they tell the build tools at the moment of definition what this function truly is, giving them time to shape it accordingly.

Some decisions must happen while the code is still being woven — not after it is already alive.

The Educational Gap — and What We Can Learn From History

If you step back and look at the confusion around directives today, you’ll notice something striking:

The problem is no longer technical — it’s educational.

We’ve repeated a historical pattern:

  • A tool-level construct looks like language
  • Developers assume it is language
  • The mental model becomes wrong
  • Confusion spreads faster than documentation can correct it

This happened with the C preprocessor.
It’s happening again with JavaScript directives.

We Need to Teach the Layering — Not Just the Feature

If we teach 'use server' as:

“This makes your function run on the server.”

…we’ve already lost.

Because that sentence hides 4 separate layers:

Layer Responsible For
Syntax writing a string literal
Loader detecting the directive
Build tools transforming / splitting code
Runtime enforcing the boundary

If developers don’t understand which layer is responsible for what, they will blame “JavaScript” for a bundler problem — just like C developers once blamed “the language” for preprocessor bugs.

What the C/C++ Community Eventually Learned

Over time, C/C++ education evolved:

Early teaching:
“Here’s how to use #define and #pragma.”

Mature teaching:
“Here’s what the preprocessor is, and why it’s separate from the language.”

After that shift, confusion dropped dramatically. No serious C++ course today teaches macros without first teaching the mental model of the compilation stages.

We need the same shift for JS directives.

Directives Aren’t Bad — They’re Powerful, If Understood

This post is not an argument against directives. They serve a purpose:

  • Conveying intent declaratively
  • Reducing boilerplate
  • Helping tools optimize and separate code
  • Giving the developer a simple switch for complex behaviors

In fact, some of the most ergonomic server/serverless features would be far more cumbersome without them.

But the price of ergonomics is clarity debt.

If we don’t teach where the magic comes from, developers misattribute the source of truth — and debugging collapses.

A Simple Mental Model to Teach (Starting Tomorrow)

I like to explain directives with a single sentence:

“A directive is a note you leave for your build tools — not for JavaScript.”

That line alone fixes 70% of misunderstandings.

Add one analogy:

“If #pragma once was not C++ syntax, then 'use client' is not JavaScript syntax either.”

And suddenly, people get it.

This doesn’t require more docs — it requires better framing.

A Practical Step Toward Clarity: A TypeScript Plugin for Directives

Before wrapping up, I want to share one more concrete step I took to reduce this confusion in real-world codebases. If part of the problem comes from directives looking like language yet lacking any formal structure, then giving them type-level meaning is one way to bridge the gap.

I built a small TypeScript plugin called typescript-plugin-directives that brings type safety and IntelliSense awareness to directives. It allows teams to:

  • define their own directive vocabulary,
  • validate them at compile time,
  • get editor hints and autocomplete,
  • and avoid silent typos like 'use clinet'.

The goal isn’t to “standardize” directives, but to make their intent explicit and visible to both developers and tooling — without needing a bundler to interpret them first.

You can try it here:

It’s intentionally lightweight — just a small layer to help the mental model click earlier, and to give directives a more formal shape inside TypeScript projects.

There’s a common assumption that a TypeScript plugin could “enforce” directives — that if the plugin knows about them, the system becomes safe by default.
But a TS plugin lives in the Language Service. It can provide awareness, warnings, and guidance — yet it still doesn’t run where code is actually transformed. It doesn’t participate in compilation or bundling.

A plugin can help identify directive usage, but it cannot enforce their semantics.
For that, the compiler needs a signal of intent — something the type system can understand.

To enable this, I expose a global Directive type in my plugin. Authors can use satisfies not to inform the editor, but to inform the TypeScript compiler that a given string is intended to be a directive:

'use server' satisfies Directive;

This doesn’t change behavior or trigger any transformation.
What it does is far simpler and more fundamental:

It tells the type system:
“Treat this as a directive — and validate it as such.”

It aligns intent with the compiler, not with runtime, and not only with the IDE.

The actual separation of worlds — hoisting inline server functions out of a component, analyzing captured scope, generating a directly callable boundary — still belongs entirely to the build. The type system can acknowledge intent, but the bundler is the one who must act on it.

One practical caveat: today, some tooling — including Next.js — expects directives to appear as bare string literals. When written as 'use server' satisfies Directive, the directive may no longer be detected, since the literal is no longer in the exact form the framework scans for. Until this changes, this pattern won’t be picked up by Next.js.

There is one more subtlety worth mentioning. This type-level intent only matters if a type-checker is actually running. Many modern toolchains — esbuild, SWC, Oxc, Bun, even most Deno and Vite setups — do not type-check at all. They simply strip types and move on. In those environments, the Directive + satisfies expression becomes a silent note to the compiler that never had a chance to listen.

Closing Reflection

The article from Tanner raises a valuable conversation — one worth having early, before bad habits ossify. I don’t believe the goal should be to eliminate directives; they clearly solve real problems. But we can learn from history, and avoid the confusion that entire generations of C/C++ developers had to unlearn.

We’ve been here before.
We know how this story goes.
This time, we can skip the decade of confusion in the middle.

Teach the layers.
Teach the provenance.
Teach the mental model.

And directives will stop feeling like “secret language features” — and start feeling like the powerful, intentional compiler hints they actually are.

Top comments (0)