Strings in Turmeric -- cstr vs str vs String

Turmeric has three string-shaped types. They are not interchangeable, and the difference that matters is ownership: who is responsible for the bytes, and how long they stay valid.

The three tiers

Type Owns bytes? Has length? Typeclasses Reach for it when ...
cstr no (borrowed const char*) no (NUL-terminated) Eq, Show string literals, FFI boundaries, static text
str no (borrowed pointer+len view) yes Eq a zero-copy sub-view over a buffer you already own
String yes (owned, immutable, refcounted) yes Eq, Ord, Show, Hash, Clone, MapKey a string that must outlive its source

cstr -- borrowed literal / FFI

cstr is a bare const char*: no stored length, no ownership. It is the type of every "..." literal and the right type at an FFI boundary. Eq[cstr] is a content strcmp (and Show[cstr] copies the bytes into a fresh owned String), so at the scalar level a cstr already behaves like a value -- but it borrows.

Note that the = operator has no cstr overload: (= a b) on two cstr values is a TUR-E0006 operator-lookup error, not a pointer compare. Content equality is (eq? a b) through the auto-loaded Eq[cstr] instance, or (cstr-eq? a b) from stdlib/cstr.tur for the direct call. The moment you store a cstr somewhere that outlives its buffer, or use a computed cstr as a Map/Set key, you have a latent dangling pointer.

str -- borrowed view

str (stdlib/str.tur) is a borrowed pointer+length view: a zero-copy window onto bytes some other buffer owns. Useful for substring-without-copy over a buffer whose lifetime you already control. Still borrowed, still not a safe owning key.

String -- owned, immutable, refcounted

String (stdlib/string.tur) owns its bytes. It is a refcounted immutable heap payload ({ rc; len; bytes[len+1] }, NUL-terminated), so:

The decision, in one line

Use cstr for a literal or an FFI call. Use String the moment the string must outlive the thing that produced it -- a returned value, a stored field, or a collection key. Use str only for a genuine zero-copy sub-view.

Literals stay cstr (that is exactly right for a literal). Do not do a big-bang cstr -> String sweep: most cstr sites are borrowed literals where cstr is correct and String would just add a copy.

Why String keys don't dangle

(load "stdlib/string.tur")

(defn make-key [] : String                 ;; bytes on the heap, not a literal
  (let [b (builder/new)]
    (do (builder/push-cstr! b "al")
        (builder/push-cstr! b "pha")
        (builder/finish b))))

(defn demo [] : int
  (let [k (make-key)
        m (map-assoc (:: (map-new) (Map String int)) k 42)]
    (do
      (string/release k)                    ;; drop the SOURCE key
      (let [probe (string/from-cstr "alpha")] ;; independent, equal key
        (do
          (println (map-get m probe))       ;; => 42  (map owns its own copy)
          (string/release probe)
          (map-free m)                      ;; frees the owned key box once
          0)))))

The same program with a computed cstr key would read freed memory after the source was dropped. MapKey[String] boxes the bytes into a key the map owns and frees exactly once (the WKC2 owned-key path), which is the whole point of the type.

API summary

Construct / convert:

Refcount:

Query:

Transform (each returns a fresh immutable String):

Incremental construction:

Typeclasses: Eq, Ord (lexicographic), Show (the bytes), Hash (content), Clone (retain), MapKey (owned key). Every op has an interpreter native override, so String behaves identically under --interpret / the REPL and when compiled.

#s"..." -- owned-String literal syntax (opt-in)

Bare "..." stays a cstr (borrowed) -- the default literal typing is deliberately unchanged. When you want an owned String literal, opt into the #s"..." reader macro shipped in stdlib/string-reader.tur:

#use-reader-macros "stdlib/string-reader.tur"   ;; enable #s"..."  (read-time)
(load "stdlib/string.tur")                        ;; the String code (eval-time)

... #s"hello" ...   ;; => (string/from-cstr "hello"), an owned String

It is two lines, and that is fundamental, not an oversight. A reader macro is registered while the file is being read; (load ...) runs later, at eval time -- after #s"..." has already been tokenized. So a plain (load "stdlib/string.tur") can never enable #s for the file that loads it; the read-time #use-reader-macros directive is what registers the syntax. The two directives are separate phases:

#s"..." works identically compiled and under --interpret, and an owned-String literal is safe as a Map/Set key. See tests/fixtures/string-reader-macro.

Zero-copy slicing -- StringSlice (opt-in)

string/substring copies. When you want ranged access without copying -- walk a range, compare sub-ranges, split, tokenize -- use StringSlice (stdlib/string-slice.tur): a bounds-checked, refcounted view { parent String; offset; len }.

It is safe in a way a raw pointer+len view over a cstr is not: a StringSlice retains its parent String, and String is immutable, so the viewed bytes can neither be freed nor mutated underneath the slice.

(load "stdlib/string-slice.tur")

(let [s (string/from-cstr "hello world")
      w (string/slice s 6 5)]        ;; "world" -- no copy
  (string/release s)                 ;; parent kept alive by the slice
  (slice/byte-at w 0)                ;; 119 ('w'); bounds-checked, -1 out of range
  (slice/sub w 0 3)                  ;; "wor" -- O(1), views the same parent
  (slice/to-string w))              ;; materialize an owned String only when needed

Surface: string/slice / string/slice-cstr (construct), slice/sub (O(1) sub-view), slice/len / slice/empty? / slice/byte-at / slice/compare / slice/eq? / slice/hash (query), slice/to-string / slice/to-cstr (materialize), slice/retain / slice/release (share/drop). Typeclasses Eq, Ord, Show, Hash. Compiled and --interpret behave identically.

Lifetimes -- the rules

Memory management & common pitfalls

The one question that resolves most string bugs: who owns these bytes, and who frees them? Each type answers it differently.

Value Who owns the bytes Your obligation
"literal" (cstr) static / the compiler none -- never free it
a cstr returned by a stdlib fn ("caller frees the result": str-concat, int->str, cstr-sub, path/*, digest/*-hex, json/encode, slice/to-cstr, ...) you (fresh heap buffer) free it, or hand it to string/adopt-cstr
a cstr from an accessor (httpd-req-*, json/get-string, sym->str, string/to-cstr) the underlying structure borrow only -- don't free, don't outlive the owner
String refcount balance each from-*/concat/retain with a string/release (or hand it to a container)
StringSlice refcount + retained parent balance each string/slice/slice/sub/slice/retain with a slice/release

Copy vs consume vs borrow -- the three cstr->String bridges

Owned builders: wrap vs build

stdlib/str-build-string ships owned-String siblings for the two foundational cstr builders -- str-concat-string (over str-concat) and cstr-sub-string (over cstr-sub) -- each a one-line string/adopt-cstr wrapper. There is a real fork in how to reach for them:

Mixed fresh/static returns -- choose per branch

Some accessors/formatters return a fresh malloc on one branch and a static string literal on another -- e.g. httpd-req-cookie (malloc on a hit, static "" when the cookie is absent) or bound->str (malloc for Inclusive/Exclusive, static "unbounded" for Unbounded). A blind string/adopt-cstr over the whole return is undefined behavior the moment the static branch fires: adopt frees its argument, and freeing a string literal is UB. The rule:

The pitfalls

The short rules

See also