diff --git a/TODO.org b/TODO.org
index 1fc05579..d8ca6c01 100644
--- a/TODO.org
+++ b/TODO.org
@@ -20,11 +20,6 @@ Dyn text stays immutable, with chars and text converting to and from a dyn
vector of characters; length and indexing count characters on dyn text and bytes on
str. Waits on the dyn-unless-annotated design.
-** NEXT type-of
-Decided 2026-09-25: (type-of x) answers a keyword — :nil :bool :int :float :text :vec
-:map :keyword, :char once dyn has one, and a class instance's class name as class-of
-does; a class named like a built-in kind is refused.
-
** NEXT Any typed container crosses into dyn as a view
Decided 2026-09-25: every element type (all numbers, chars, structs, nested arrays)
and any storage; a dev build checks a view against its frame or allocation and traps
diff --git a/emacs/flan-mode.el b/emacs/flan-mode.el
index 1efff081..24878704 100644
--- a/emacs/flan-mode.el
+++ b/emacs/flan-mode.el
@@ -167,7 +167,7 @@ face says.")
;; Map
"map-new" "put" "get" "map-remove" "map-next" "has-key?"
;; dyn
- "class-of" "keyword"
+ "class-of" "type-of" "keyword"
;; compile time
"embed" "embed-dir" "compile-error"
;; files
diff --git a/lib/check.ml b/lib/check.ml
index a3065a85..9d0f833c 100644
--- a/lib/check.ml
+++ b/lib/check.ml
@@ -11266,6 +11266,19 @@ and named_call ?(qualified = false) ctx ~want loc name args =
(rt loc Types.Dyn "flan_dyn_class_of" [ check ctx ~want:Types.Dyn v ])
| _ -> assert false)
+ (* (type-of v) -> the value's kind as a keyword, or a class instance's
+ class name. A typed argument crosses into dyn first, as it does for
+ class-of and every other dyn builtin, so it answers the kind the crossing
+ makes of it: a typed i32 is :int and an f32 is :float. That keeps one
+ answer per value whichever side of the program holds it. *)
+ | "type-of" ->
+ arity ctx loc name 1 args;
+ (match args with
+ | [ v ] ->
+ expect ctx loc ~want
+ (rt loc Types.Dyn "flan_dyn_type_of" [ check ctx ~want:Types.Dyn v ])
+ | _ -> assert false)
+
(* (map-remove m k) -> (Option V): the value that was there, or None when
the key was not. The same answer [get] gives, for the same reason — a key
that is not in the map is an answer and not a failure — and the value
@@ -13757,6 +13770,11 @@ let builtins : (string * string * string) list =
It is what a defgeneric dispatches on, so a class dispatcher is this \
call over the first argument and a defmulti whose body is (class-of x) \
is the same generic function written the other way.");
+ ("type-of", "type-of [dyn] dyn",
+ "The value's kind as a keyword — :nil :bool :int :float :text :vec \
+ :keyword :map — or, for a value built by a defclass constructor, the \
+ class's name as class-of answers it. A typed value answers the kind it \
+ has as a dyn value: an i32 is :int.");
("keyword", "keyword [string|[const u8]] dyn",
"The interned dyn keyword named by the bytes, for a name that only \
exists at run time — a reader building :texture-path out of a token's \
diff --git a/lib/emit.ml b/lib/emit.ml
index fd4f6423..c451a247 100644
--- a/lib/emit.ml
+++ b/lib/emit.ml
@@ -4993,6 +4993,7 @@ declare void @flan_dyn_slot_init(i64, i64, i64, ptr, i64)
declare void @flan_dyn_ctor_site(ptr, i64)
declare void @flan_dyn_map_put(i64, i64, i64, ptr, i64)
declare i64 @flan_dyn_class_of(i64)
+declare i64 @flan_dyn_type_of(i64)
declare void @flan_dyn_class_def(i64, ptr, i64)
declare void @flan_dyn_class_hook(ptr)
declare i64 @flan_dyn_kw(ptr, i64)
diff --git a/lib/parse.ml b/lib/parse.ml
index 5a76c9ab..4651dd4c 100644
--- a/lib/parse.ml
+++ b/lib/parse.ml
@@ -1682,6 +1682,22 @@ let rec decl (f : Form.t) : Ast.decl =
[[x y]] two dyn slots, [[pause bool step bool]] two typed ones — and
is carried undecided for the same reason. *)
| [ n; { v = Vec slots; _ } ] ->
+ (* type-of answers a class instance's class name and any other value's
+ kind, so a class named like a kind would make :map (say) mean two
+ things. Checked before [tname] so bool, int and float get this
+ sentence too. char is reserved for dyn's char kind. *)
+ (match n.v with
+ | Sym s
+ when List.mem s
+ [ "nil"; "bool"; "int"; "float"; "text"; "vec"; "map";
+ "keyword"; "char" ] ->
+ Loc.failk "parse/class-named-kind" n.loc
+ "%s is the name of a kind of dyn value, so (type-of x) would \
+ answer :%s both for an instance of this class and for a plain \
+ %s. Name the class %s-value, or any name that is not one of \
+ nil bool int float text vec map keyword char"
+ s s s s
+ | _ -> ());
List.iter
(fun (s : Form.t) -> match s.v with Sym _ -> no_sigil s | _ -> ())
slots;
diff --git a/runtime/flan_dyn.c b/runtime/flan_dyn.c
index ea488a33..2a5516d3 100644
--- a/runtime/flan_dyn.c
+++ b/runtime/flan_dyn.c
@@ -2295,6 +2295,27 @@ flan_dyn flan_dyn_class_of(flan_dyn v) {
return dyn_make(BOX_KW, (uint64_t)(uintptr_t)o->u.v.klass);
}
+/* The kind of a value as a keyword named by [tag_words], or a class instance's
+ * class name as [flan_dyn_class_of] answers it. A class may not be named like
+ * a kind (the parser refuses it), so :map always means a plain map. Keywords
+ * are immortal, so each kind's keyword is interned once and kept. When dyn
+ * gains a char, its tag gets a word in [tag_words] and :char falls out here
+ * with no change to this function. */
+flan_dyn flan_dyn_type_of(flan_dyn v) {
+ static flan_dyn kinds[FLAN_DYN_TAG_MAP + 1];
+ static int interned;
+ int32_t t = flan_dyn_tag(v);
+ if (t == FLAN_DYN_TAG_MAP && dyn_obj(v)->u.v.klass != NULL)
+ return flan_dyn_class_of(v);
+ if (!interned) {
+ for (int i = 0; i <= FLAN_DYN_TAG_MAP; i++)
+ kinds[i] = flan_dyn_kw((const uint8_t *)tag_words[i],
+ (int64_t)strlen(tag_words[i]));
+ interned = 1;
+ }
+ return kinds[t];
+}
+
/* ── Keywords ──────────────────────────────────────────────────────────
*
* One global table, append-only, never freed: a keyword is a *name*, the set
diff --git a/runtime/flan_dyn.h b/runtime/flan_dyn.h
index 7667650b..a029206a 100644
--- a/runtime/flan_dyn.h
+++ b/runtime/flan_dyn.h
@@ -133,6 +133,11 @@ void flan_dyn_slot_set(flan_dyn m, flan_dyn k, flan_dyn v,
* — an ordinary map included. Never traps. */
flan_dyn flan_dyn_class_of(flan_dyn v);
+/* The value's kind as a keyword — :nil :bool :int :float :text :vec :keyword
+ * :map — or, for a class instance, its class name as [flan_dyn_class_of]
+ * answers it. Never traps. */
+flan_dyn flan_dyn_type_of(flan_dyn v);
+
/* A class definition, registered or re-registered: [name] is the class's name
* as a keyword and [slots]/[n] is its slots packed into one string, a line
* each, the slot's name and then — after a space, for a typed slot — its
diff --git a/test/programs/dyn-type-of.flan b/test/programs/dyn-type-of.flan
new file mode 100644
index 00000000..f5676b99
--- /dev/null
+++ b/test/programs/dyn-type-of.flan
@@ -0,0 +1,36 @@
+;;;; type-of: every value's kind as a keyword, and a class instance's class
+;;;; name, which is what class-of answers for it.
+
+(defclass point [x y])
+
+;; A defmulti over type-of is dispatch on the kind of the first argument.
+(defmulti describe [v] dyn (type-of v))
+(defmethod describe :int [v] "a whole number")
+(defmethod describe :text [v] "some text")
+(defmethod describe :point [v] "a point")
+(defmethod describe :else [v] "something else")
+
+(defn main [] i32
+ (let [n (the i32 7)
+ f (the f32 1.5)
+ b (the bool true)
+ s (the string "typed")]
+ (println (type-of nil))
+ (println (type-of true))
+ (println (type-of 42))
+ (println (type-of 2.5))
+ (println (type-of "hello"))
+ (println (type-of [1 2 3]))
+ (println (type-of {:a 1}))
+ (println (type-of :k))
+ (println (type-of (point 1 2)))
+ ;; Typed values cross into dyn first and answer the kind they become.
+ (println (type-of n))
+ (println (type-of f))
+ (println (type-of b))
+ (println (type-of s))
+ (println (describe 3))
+ (println (describe "x"))
+ (println (describe (point 3 4)))
+ (println (describe [1]))
+ (if (= (type-of (point 0 0)) (class-of (point 0 0))) 0 1)))
diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml
index dd1821ca..53e2390f 100644
--- a/test/test_acceptance.ml
+++ b/test/test_acceptance.ml
@@ -5298,6 +5298,18 @@ level "1"
"programs/dyn-class.flan" dyn_class_out;
outputs ~x86:true "dyn: classes and dispatch, --x86"
"programs/dyn-class.flan" dyn_class_out;
+ (* type-of over each kind, a class instance, four typed values crossing
+ into dyn, and a defmulti dispatching on it. *)
+ let dyn_type_of_out =
+ ":nil\n:bool\n:int\n:float\n:text\n:vec\n:map\n:keyword\n:point\n\
+ :int\n:float\n:bool\n:text\n\
+ a whole number\nsome text\na point\nsomething else\n"
+ in
+ outputs "dyn: type-of" "programs/dyn-type-of.flan" dyn_type_of_out;
+ outputs ~opt:"-O0" "dyn: type-of, -O0"
+ "programs/dyn-type-of.flan" dyn_type_of_out;
+ outputs ~x86:true "dyn: type-of, --x86"
+ "programs/dyn-type-of.flan" dyn_type_of_out;
(* (watch "name" v) with nothing arming the table: a struct, an array, a
slice, a dyn map and a string all compile against flan_dev.c's watch
entry points on both backends, write nothing, and evaluate the value
diff --git a/test/test_dev.ml b/test/test_dev.ml
index 72a203bb..e854c406 100644
--- a/test/test_dev.ml
+++ b/test/test_dev.ml
@@ -9215,7 +9215,11 @@ let () =
answers what it always did, which is what keeps every method
ever written for this class reachable. *)
holds "the instance is still an instance of its class"
- "(if (= (class-of (at instances 1)) :point) 1 0)"
+ "(if (= (class-of (at instances 1)) :point) 1 0)";
+ holds "type-of answers the class name in a session"
+ "(if (= (type-of (at instances 1)) :point) 1 0)";
+ holds "and a kind for anything that is not an instance"
+ "(if (= (type-of (get (at instances 1) :w)) :nil) 1 0)"
end;
(* ── A definition that did not change ──
diff --git a/test/test_flan.ml b/test/test_flan.ml
index 90ce6d94..c1b4cf37 100644
--- a/test/test_flan.ml
+++ b/test/test_flan.ml
@@ -2974,6 +2974,20 @@ let () =
"(defclass marker [])\n(defn main [] i32 (let [m (marker)] 0))";
accepts "class-of answers nil for anything that is not an instance"
"(defn main [] i32 (if (= (class-of 1) nil) 0 1))";
+ (* type-of answers a class instance's name, so a class may not take the
+ name of a kind: :map would mean a plain map and an instance at once.
+ bool, int and float are also built-in types; they get this sentence. *)
+ List.iter
+ (fun k ->
+ rejects_check ("a class may not be named " ^ k)
+ (Printf.sprintf "(defclass %s [a])\n(defn main [] i32 0)" k)
+ ~needle:(Printf.sprintf "Name the class %s-value" k))
+ [ "nil"; "bool"; "int"; "float"; "text"; "vec"; "map"; "keyword"; "char" ];
+ accepts "the fix a class-named-kind refusal offers compiles"
+ "(defclass map-value [a])\n\
+ (defn main [] i32 (if (= (type-of (map-value 1)) :map-value) 0 1))";
+ rejects_check "type-of takes one argument"
+ "(defn main [] i32 (let [k (type-of 1 2)] 0))" ~needle:"type-of";
(* The constructor is an ordinary function, so its arity is the ordinary
arity check and a wrong one names the class. *)
rejects_check "a constructor takes one argument per slot"
diff --git a/web/index.html b/web/index.html
index cd8e96ef..a82b34ce 100644
--- a/web/index.html
+++ b/web/index.html
@@ -720,7 +720,11 @@ and a slot may be bool, an integer type, f32,
f64, string, a class, or (Option T) of one of
those, which also admits nil. The constructor is the class's own name
and is positional, and class-of answers the tag, or nil
-for anything that is not an instance. The slots are map keys: get
+for anything that is not an instance. type-of answers any value's
+kind as a keyword — :nil, :bool, :int,
+:float, :text, :vec, :map or
+:keyword — and an instance's class name, so a class cannot be named
+after one of those kinds. The slots are map keys: get
reads one, and set writes one, as in
(set (get s :pause) true). put writes one too, and is
also how a key the class does not declare is added.