Skip to main content

Command Palette

Search for a command to run...

Why handleLs args Exists and handleDir(args) Doesn’t — A Type-Driven Lesson from F#

Updated
3 min read
Why handleLs args Exists and handleDir(args) Doesn’t — A Type-Driven Lesson from F#

Why handleLs args — and Not handleDir(args)

A Theory-Driven Explanation from the F# Type System

When building a shell-like system in F#, it’s natural to think in terms of concepts.

You see a directory listing command and instinctively reach for a name like:

handleDir args

Then the compiler responds with something like:

FS0039: The value 'handleDir' is not defined. Maybe you want: handleLs, handleTree

At first glance, this feels like a trivial naming issue. Just rename the function, right?

But this error has nothing to do with directories.

It’s about symbol resolution, function contracts, and how F# encodes meaning through types.


No Name, No Existence

F# is a statically typed, symbol-driven language.

A function exists only if it is explicitly defined and in scope. There is:

  • no implicit meaning

  • no semantic guessing

  • no late binding

If a symbol is not defined, it does not exist — full stop.

So when the compiler says “Maybe you want: handleLs, handleTree”, it’s not being helpful about spelling. It’s telling you something more important:

The abstraction you tried to invent does not exist — but more precise ones already do.

That’s where the real lesson begins.


In F#, Identity Comes from the Type Signature

In F#, the identity of a function is not its name. It is its type signature.

The compiler does not reason about intent (“this is a directory command”). It reasons about contracts:

  1. How many arguments does the function take?

  2. What are the types of those arguments?

  3. What is the result type?

This is why the difference between the following is semantic, not stylistic:

handleDiskInfo () handleLs args


unit vs Arguments Is a Design Statement

The empty parentheses () represent the unit type.

A function that accepts unit is declaring something very specific:

This operation takes no information from the caller.

Its behavior is invariant. The caller cannot influence it.

That maps cleanly to commands like:

  • df

  • uptime

These commands are conceptually invoke-and-report operations.

By contrast, a function that accepts string[] is explicitly stating:

My behavior depends on externally supplied input.

That maps to commands like:

  • ls

  • du

  • tree

These commands represent a family of behaviors, parameterized by paths, flags, or options.


Unix Philosophy, Enforced at the Type Level

This distinction isn’t accidental — it’s Unix philosophy encoded into the type system.

Command TypeConceptual ModelF# Signature
Fixed behaviorSingle invariant operationunit -> unit
Variable behaviorParameterized familystring[] -> unit

Trying to collapse everything into a generic:

handleDir args

erases this distinction.

It pushes meaning out of the type system and into runtime logic — where it becomes harder to reason about, easier to misuse, and impossible for the compiler to enforce.

F# resists this by design.


The Compiler Is Not Complaining — It’s Reviewing Your Design

When the compiler suggests handleLs or handleTree, it’s not saying:

“You misspelled something.”

It’s saying:

“You already modeled this more precisely than what you’re attempting now.”

This leads to the correct mental model:

F# function signature == command interface contract

  • If a command conceptually varies with input, its function must accept parameters.

  • If it does not, its function must accept unit.

This is not ceremonial verbosity. It is how correctness is enforced early, mechanically, and without ambiguity.


The Real Takeaway

Static typing is often framed as a way to prevent mistakes.

That’s underselling it.

Static typing prevents bad abstractions.

It forces you to commit to a model of behavior — and makes imprecise models impossible to express.

Once you see this, the compiler stops feeling strict and starts feeling honest.

The syntax difference is just the surface. The rule underneath is the point.