Imagine you ask someone to build you a bookshelf. They deliver it. It’s beautiful. It has shelves, screws, everything in place. You lean it against the wall, and it collapses. The screws are fake. They look like screws, but they’re made of plastic.

That’s what an LLM does when it abuses the type system. It gives you code that compiles, passes tests, and looks correct. But under the hood, where there should be meaningful types, there are strings. Where there should be explicit state, there’s a nil that means three different things depending on who reads it. And where there should be an enum with two cases, there’s a == "claude" that one day someone will misspell, and no one will notice until production.

The Model’s Favorite Shortcut: Universal Strings

I’ve spent months working with an AI agent on a medium-sized Swift app. The code it generates is clean, well-structured, with good naming. And yet, there’s one pattern that repeats itself over and over: the model avoids creating new types.

It’s not malicious. It does this because creating a new type requires making design decisions: is it an enum? How many cases does it have? Where does it live? Who imports it? A String doesn’t require any decision. It works anywhere. It always compiles.

The result is code like this:

func sessions(harness: String? = nil) -> [Session] {
    if let harness {
        return all.filter { $0.harness == harness }
    }
    return all
}

// Usage:
let claudeSessions = sessions(harness: "claude")
let codexSessions = sessions(harness: "codex")
---

It looks fine. It works. But there are three hidden problems:

1. **No one will warn you if you write `"cladue"` by mistake.** A _string_ will accept anything. An _enum_ won’t.
2. **`nil` means "all",** but that’s not encoded in any type. It’s just a convention that lives in your head and in a comment no one reads.
3. **Every function filtering by harness repeats the same `String? = nil` pattern.** If you add a third harness tomorrow, you’ll have to hunt down every scattered string comparison in the code.

In other words, the compiler can’t help you because you’ve stripped away all the semantic information. You’ve given it a `String` where a domain concept should exist.

## The Version It Should Have Written

```swift
enum HarnessID: String, Codable {
    case claude
    case codex
}

enum HarnessFilter {
    case all
    case specific(HarnessID)
}

func sessions(harness: HarnessFilter = .all) -> [Session] {
    switch harness {
    case .all:
        return all
    case .specific(let id):
        return all.filter { $0.harnessID == id }
    }
}

// Usage:
let claudeSessions = sessions(harness: .specific(.claude))
let allSessions = sessions()  // .all by default — explicit

Now "cladue" doesn’t compile. “All” isn’t some magical nil; it’s an explicit case in the enum. And if you add a third harness, the compiler forces you to handle the new case in every switch. It’s turned runtime errors into compile-time errors. This is what a type system should do.

Nil as a Catch-All Drawer

The second favorite shortcut is using nil to represent something that has its own meaning. The model loves optionals. And it makes sense: an optional field is the fastest way to add data without breaking anything. But there’s a massive difference between “this value might not exist” and “this value has a specific state I’m encoding as absence.”

Here’s a real example:

var calibrationDate: Date?  // nil = never calibrated
var quotaPercent: Double?   // nil = unknown
var errorMessage: String?   // nil = no error

Three fields, three different meanings for nil. The first one is legitimate: it’s possible it’s never been calibrated. The second is questionable: “unknown” is a state that deserves its own type. The third is dangerous: you’re using the absence of an error to represent success, and the presence of a string to represent failure. That’s a Result disguised as an optional.

To the compiler, all three cases look the same: Optional<T>. It can’t tell the difference between “legitimately absent” and “I’m using nil as a boolean flag.” And if the compiler can’t see it, neither will you six months later when you reread the code.

Why the LLM Does This

It’s not laziness. It’s optimization for the wrong goal.

The model is trained to generate code that compiles and passes tests. A String always compiles. A nil always compiles. A new enum requires you to define it, import it, and update all the call sites. From the model’s perspective, the string involves less friction and achieves the same immediate result.

It’s exactly like a junior developer using any in TypeScript. It’s not that they don’t know better types exist. It’s that any compiles, and the ticket gets closed. The incentive is misaligned.

What the Model Optimizes ForWhat You Need
Compiling on the first tryThe compiler catching issues for you
Passing existing testsMaking new errors impossible
Minimal code changesMaximum semantic information
Solving today’s problemAvoiding problems tomorrow

First Line of Defense: A Type Linter

When I realized the model kept repeating these patterns, my first instinct was the usual: write rules in CLAUDE.md. “NEVER use String where an enum belongs.” “NEVER use nil as a flag.”

We all know how that ends. The model reads the rule, nods, and three generations later sneaks in a harness: String? = nil again. Not out of rebellion — because the string is the path of least resistance, and the 200K token context has pushed your rule out of its attention span.

So I wrote a linter. A bash script that scans the source code for suspicious patterns:

# T4: Literal comparison to values that should be enums
fail_if_found "T4" "ERROR" \
    'Comparison == "claude" / == "codex" (should be == .claude)' \
    '==\s*"(claude|codex)"'

# T7: nil as "all" in filter parameters
fail_if_found "T7" "ERROR" \
    'Parameter harness: String? = nil (should be HarnessFilter)' \
    'harness:\s*String\?\s*='

# T1: ExpressibleByStringLiteral in domain types
fail_if_found "T1" "ERROR" \
    'ExpressibleByStringLiteral reintroduces stringly-typed' \
    'ExpressibleByStringLiteral'

Eight checks total. Each one searches for a specific pattern using grep. If it finds a match, it fails. No interpretation, no judgment, no “well, in this case it makes sense.” If the pattern is found, CI goes red.

Not gonna happen, model. That == "claude" isn’t getting through.

The linter is deliberately dumb. It doesn’t parse the AST, doesn’t understand context, and doesn’t use fancy heuristics. It looks for text. And that’s an advantage: it’s impossible to outthink. The model can’t rationalize an exception if the detection is purely textual.

Second Line of Defense: Auditing Skills

The linter catches symptoms. But the symptoms are the consequence of a deeper problem: the model doesn’t stop to think about whether the type it’s using is the correct one.

For that, I created an auditing skill — a Markdown file the agent executes on demand, systematically reviewing type usage in any module. The skill highlights:

  1. Strings representing finite sets. If a field only has 3 possible values, it should be an enum, not a String.
  2. Optionals representing states. If nil means something specific (“unknown,” “N/A,” “all”), it should be a case in an enum.
  3. Dictionaries with string keys where the key is a domain concept. [String: Session] should be [HarnessID: Session].
  4. setValue(forKey:) and similar methods that bypass Core Data’s type system.
  5. Dictionary(uniqueKeysWithValues:) without ensuring unique keys — runtime crash waiting to happen.

The skill isn’t magical. It’s just a checklist that forces the model to review the code with a focused lens. The difference between “check the types” (vague) and “find String fields with N known values” (specific) is the difference between a useful audit and a “looks fine to me.”

The Before and After

After running the linter and auditing a module that had been in development for two months:

FindingCountSeverity
Comparisons == "string literal"4Error: invisible typo at runtime
Parameters String? = nil as filters3Error: hidden semantic nil
Strings masquerading as enums2Refactor: lost type safety
setValue(forKey:) in Core Data1Error: compiler cannot validate field name

Ten findings. All introduced by the model. None caught by the existing tests — because the tests used the same strings as the code. Fiction validating fiction, yet again.

After refactoring, the code had 40 more lines (to define enums) and zero loose strings. Every comparison was compiler-checked. Every filter had an explicit type. And the next bug where “the model wrote "cladue" by mistake” became impossible.

What You Can Do Today in Your Project

You don’t need Swift. You don’t need my linter. The pattern applies to any typed language:

In TypeScript:

// Before: stringly-typed
function getUsers(role: string = "all") { ... }
getUsers("adnin")  // typo, compiles, fails at runtime

// After: type-safe
type Role = "admin" | "viewer" | "billing"
type RoleFilter = Role | "all"
function getUsers(role: RoleFilter = "all") { ... }
getUsers("adnin")  // compile error

In Python with Enum:

# Before
def process(status: str | None = None): ...
process("pending")  # “pending” or “Pending” or “PENDING”?

# After
class Status(Enum):
    PENDING = "pending"
    DONE = "done"

def process(status: Status | None = None): ...

The general rule: if a value can only take N known options, it should be a type with N cases, not a string with infinite possibilities. If nil means something other than “absent,” it deserves a name of its own.

And if you’re working with an AI agent, set up a linter to catch suspicious patterns. It doesn’t have to be sophisticated. A grep with domain-specific patterns will do. The important thing is that it runs in CI, is automatic, and doesn’t depend on the model reading your README.

The Meta-Problem

There’s something ironic about all this. We’re asking a language model — a system that fundamentally operates with strings — to stop using strings where it shouldn’t.

It’s like asking a carpenter to stop using wood. Of course it can, but its instinct leads it there. Strings are the LLM’s native type. Everything it sees is text. Everything it generates is text. When it has to choose between a rich semantic type and a string that “just works,” the string wins by default.

The solution isn’t to ask it to change. It’s to tackle the problem with tools that automatically detect the pattern and block it before it hits main. A dumb linter beats a smart model if the linter always runs and the model forgets sometimes.

Your types are your living documentation. If you degrade them to strings, your code compiles but communicates nothing. And the next person to read it — whether it’s you, a teammate, or another model — will have to guess what that nil meant.

Don’t make them guess. Make the wrong path fail to compile.