---
title: "Structs"
scope: "core"
kind: "reference"
category: "features"
aliases:
  - "struct type"
  - "field access"
  - "struct fields"
id: core/features/structs
parent: core/features
---

# Structs

Calcit structs are declared data types with a fixed set of named fields. Struct definitions are created with `defstruct`; struct values are constructed by calling the definition directly with tag/value pairs; the low-level `%{}` macro is kept for dynamic prototypes.

## Quick Recipes

- **Define**: `defstruct Point (:x Number) (:y Number)`
- **Create**: `Point :x 1 :y 2`
- **Low-level (dynamic prototype)**: `%{} Point (:x 1) (:y 2)`
- **Access**: `(:x p)`
- **Update**: `assoc p :x 10` or `update p :x inc`
- **Type Check**: `assert-type p 'Point`

## Defining a Struct Type

Use `defstruct` to declare a named type with typed fields:

```cirru
defstruct Point (:x Number) (:y Number)
```

每个字段写成 `(:field-name type)`。类型名直接写裸符号，如 `Number`、`String`、`Bool`、`Tag`、`Dynamic`（不限类型）或已声明的 `Point`。`defstruct` 不会把类型名当表达式求值，所以同名的局部绑定不会影响字段类型。带参数的复合类型同样直接写，如 `:tags $ List String`、`:scores $ Map Tag Number`；注意保留 `$` 或括号，`(:tags List String)` 会被读成类型 `List` 加默认值 `String`。旧写法 `quote $ List String` 仍可使用，`calcit edit format` 会去掉 quote。

旧写法 `'Number` 仍然可用，`calcit edit format` 会把它改为 `Number`，见[升级手册](../run/upgrade.md)。

```cirru
defstruct Person (:name String) (:age Number) (:position Tag)
```

### 字段类型的声明来源

字段中的类型名按 `defstruct` 所在 namespace 及其显式 import 解析，而不是按调用者解析。
例如 `Database` 声明 `:users $ Map String User` 时，其他 namespace 通过普通 import
构造、读取或更新该字段，仍使用声明处的 `User`；调用者自己也定义了 `User`，不代表两者可以互换。
这个规则同样覆盖 `Option` 和嵌套容器。泛型字段先解析声明中的具名类型，再代入调用方已证明的参数，
不需要把合法本地类型名全部改为全限定名，也不需要增加 `unsafe-coerce`。

内建 schema 名（如 `StructDef`、`JsObject`）优先解释为内建类型标记，即使同一 namespace
声明了同名类型。引用这些同名自定义类型时使用完整的 `namespace/definition`；完整引用
不会被截短为内建名，不同 namespace 的定义仍然可区分。这是既有的 native 解析优先级。

容器字段可以接收对应种类的字面量空容器，例如 `Map<Tag,String>` 字段接收 `{}`。空 Map、List、Set 的种类仍有区别；
声明为开放容器的函数返回值不因当前实现返回空值而获得具体元素类型证明。

## 定义值的相等性

在 native 与 JS 中，`StructDef` 的 `=` 比较定义本身：声明来源、名称、字段及其类型、
泛型参数、约束和附加 impl 都参与比较。类型的兼容写法按语义比较，例如字段声明
`Number`、`'Number` 与旧写法 `:number` 相等；生成的字段校验闭包不参与定义相等性。

`struct-definition` 返回实例携带的定义。Cirru EDN 恢复的旧 Struct 可能缺少声明来源、
字段类型与 impl，不能仅凭相同名称和字段名，就把它视为当前模块的类型定义。
需要将旧数据迁移为当前类型时，通过既有 decoder 重建并验证；需要确认是同一个运行时
定义对象时使用 `identical?`。定义值比较不替代实例的类型证明，也不改变实例的相等规则。

定义可作为 Map key 或 Set 成员；相等定义具有相同 hash，排序比较为 0。
不同模块里的同名类型仍有不同来源；普通引用和别名保留原类型来源，不需要改成 npm 路径。

## Generic Structs

`defstruct` 可以在类型名后接泛型参数列表。泛型参数和字段类型一样直接写名字（`[] T`），在字段类型和 `assert-type` 里直接写 `Box Number` 套用具体类型。

```cirru
let
    Box $ defstruct Box ([] T) (:value T)
    keep $ fn (box)
      hint-fn $ Fn (Box T) (-> T) (:generics T)
      :value box
    b $ Box :value 1
  assert-type b $ Box Number
  assert= 1 $ keep b
```

Use this pattern when the struct definition owns the type variable. Function-level constraints such as `:where` still stay on the function schema, not on `defstruct` itself.

### 泛型参数的证据

构造器从实际字段值推导泛型参数。同一参数出现在多个字段中时，它们共享同一次类型替换；
例如 `Reel<Db>` 的 `base: Db` 与 `db: Db` 不能分别放入两个不同名义类型的数据库。
不参与该参数的字段独立检查：`records: List<List<Dynamic>>` 不会把 `Db` 擦除成 Dynamic，
不同泛型参数之间也不会因其中一个 payload 显式开放而失去其余参数的证据。

应用后的类型证据经 `let`、构造器别名、嵌套字段、函数参数与返回值传递；
函数 schema 中的本地名及 import alias 按声明 namespace 解析。
普通字段方法沿用同一关系，不需要换成 native call 或添加转换断言。
需要 `Reel<DbA>` 的参数必须得到对应的实际证据，裸 `Reel` 或 `Reel<Dynamic>`
不能仅凭目标 schema 冒充它。开放值可以保存在明确开放的 payload 中，具体使用前仍需验证或收窄。

### Generic Structs with `where` Bounds

`defstruct` may also take a `where` map right after the optional generics list. This lets the struct definition itself require that a type variable implements one or more traits.

```cirru
let
    ShownBox $ defstruct ShownBox ([] T)
      {} $ T Debug
      :value T
    box $ ShownBox :value 1
    item $ :value box
  assert-type box $ ShownBox Number
  assert= |1 $ item .debug
```

Here `{} $ T Debug` means `T` must satisfy the built-in `Debug` trait. The `{}`
line already becomes a list through indentation, so do not add another outer pair
of parentheses around it or around `:value T`. `%{}` enforces the bound when
constructing a struct value, so the constraint lives on the data definition rather
than on each individual function schema. Use `Show` instead only when the payload
has an explicit user-facing presentation implementation.

## Creating Struct Values

When the definition is in scope, the concise constructor form keeps field names at
the call site. It works for top-level definitions and for a `defstruct` bound by
`let`:

```cirru
let
    Point $ defstruct Point (:x Number) (:y Number)
    p $ Point :x 1 :y 2
  , p
```

Arguments must be tag/value pairs. Required fields must be present, while a field
declared as `Option<T>` may be omitted; the constructor inserts the nominal
`Option :none` 值；非 `Option` 字段绝不会被静默填成 `nil`。

### 字段默认值

字段可以写第三项作为默认值。构造时省略该字段，就得到声明的默认值；读取时字段类型仍是声明的类型，不需要 unwrap：

```cirru
let
    Config $ defstruct Config (:host String) (:port Number 8080) (:debug? Bool false) (:tags (List String) ([]))
    config $ Config :host |localhost
  assert= 8080 $ :port config
  assert= false $ :debug? config
  assert= ([]) $ :tags config
  assert= 3000 $ :port $ Config :host |localhost :port 3000
```

默认值只能是字面量：数字、字符串、tag、布尔值，以及空的 `([])`、`({})`、`(#{})`。定义时按字段类型检查，类型不符或不是字面量时 `defstruct` 报错。需要计算的初始值写成普通的构造函数。

所有字段都有默认值或是 `Option` 时，不带参数调用构造器就得到全部默认值，例如 `Config` 去掉 `:host` 后可以写 `(Config)`。

选择默认值还是 `Option`：值可能不存在、并且“不存在”要和任何具体值区分时用 `Option`，读取得到 `Option T`；值总是存在、只是多数调用者用同一个值时用默认值，读取直接得到 `T`。

默认值由构造器和下文的 map 字面量改写在编译期补齐，生成的构造调用与手写全部字段相同；低层 `%{}` 不补默认值。

低层 `%{}` 宏只用于动态 prototype；类型能静态解析时使用上面的直接构造器。它的写法如下：

```cirru
let
    Point $ defstruct Point (:x :number) (:y :number)
    p $ %{} Point (:x 1) (:y 2)
  , p
```

## Accessing Fields

Use the required field accessor `(:field value)` to read a field:

```cirru
let
    Point $ defstruct Point (:x :number) (:y :number)
    p $ Point :x 1 :y 2
  println $ :x p
  ; => 1
```

Required field access only accepts a statically known, typed Struct and returns
the field's declared type directly. A missing field or an untyped receiver is a
checking error. It never changes into an `Option` lookup merely because type
information is missing.

静态已知的 Struct 不使用 `get`：必填字段继续写成 `(:field value)`，让检查器证明字段存在并直接返回
声明类型。只有 receiver 已被显式声明为 `Dynamic`、调用者确实需要按运行时 tag 做可选查询时，普通
`get` 才会在运行值为 Struct 时返回 `Option<Dynamic>`。这样“静态必填字段”和“开放数据可选查询”
在源码中保持可见区别，也不需要增加平行的 dynamic API。

Nested nominal fields keep the namespace where their declaration was written.
This means a concise same-namespace field type such as `'Router` remains
statically resolvable even when the outer Struct flows into another namespace:

```cirru.no-check
defstruct Router $ :name String

defstruct ClientStore $ :router Router

defn router-name (store)
  hint-fn $ Fn app.schema/ClientStore (-> String)
  :name $ :router store
```

If a diagnostic still shows an unresolved short receiver such as `'Router`, fix
or qualify the schema/dependency reference. Do not replace the typed read with
`&struct:get`; that only hides the missing declaration context.

Loose/anonymous structs (`?{}` or `%{} _ ...`) also cannot be read through the
required field accessor until an expected named Struct type rewrites them. This
prevents an undeclared field from silently becoming `Dynamic` and forces the
schema to be established before application code depends on it.

`count` and `contains?` work on structs. Convert a Struct to its typed Map view before requesting keys:

```cirru
let
    Point $ defstruct Point (:x :number) (:y :number)
    p $ Point :x 1 :y 2
  println $ &map:keys $ &struct:to-map p
  ; => $ #{} :x :y
  println $ count p
  ; => 2
  println $ contains? p :x
  ; => true
```

## Updating Fields

Struct values are immutable. Use `assoc` or `struct-with` to produce an updated copy:

```cirru
let
    Point $ defstruct Point (:x :number) (:y :number)
    p $ Point :x 1 :y 2
    p2 $ assoc p :x 10
  println p2
  ; => $ %{} :Point (:x 10) (:y 2)
  println p
  ; p is unchanged: $ %{} :Point (:x 1) (:y 2)
```

```cirru
let
    Person $ defstruct Person (:name :string) (:age :number) (:position :tag)
    p $ Person :name |Chen :age 20 :position :mainland
    p2 $ struct-with p (:age 21) (:position :shanghai)
  println p2
  ; p2 has updated :age and :position, :name is unchanged
```

`&struct:assoc` is the low-level variant:

```cirru
let
    Point $ defstruct Point (:x :number) (:y :number)
    p $ Point :x 1 :y 2
  println $ &struct:assoc p :x 100
```

The low-level `&%{}` form accepts fields as flat keyword-value pairs (no type checking):

```cirru.no-check
let
    Person $ defstruct Person (:name :string) (:age :number) (:position :tag)
  println $ &%{} Person :name |Chen :age 20 :position :mainland
```

## Type Checking

```cirru
let
    Point $ defstruct Point (:x :number) (:y :number)
    p $ Point :x 1 :y 2
  ; check if a value is a struct
  println $ struct? p
  ; => true
  ; check if it matches a specific struct
  println $ &struct:matches? p Point
  ; => true
  ; get the definition used to construct the value
  println $ struct-definition p
  ; struct-def? is the definition predicate
  println $ struct-def? Point
  ; => true
  println $ struct-def? p
  ; => false
```

## Pattern Matching

Use `struct-match` to branch on struct definitions:

```cirru
let
    Circle $ defstruct Circle (:radius :number)
    Square $ defstruct Square (:radius :number)
    shape $ Circle :radius 5
  struct-match shape
    Circle c $ * 3.14
      * (:radius c) (:radius c)
    Square s $ * (:radius s) (:radius s)
    _ _ 0

; => 78.5
```

## Converting Structs

### To Map

```cirru
let
    Point $ defstruct Point (:x :number) (:y :number)
    p $ Point :x 1 :y 2
  println $ &struct:to-map p
  ; => {} (:x 1) (:y 2)
```

Use `struct-with` to update fields while preserving the same struct definition:

```cirru
let
    Person $ defstruct Person (:name :string) (:age :number) (:position :tag)
    p $ Person :name |Chen :age 20 :position :mainland
  println $ struct-with p (:age 23) (:name |Ye)
```

## Struct Name and Definition Inspection

```cirru
let
    Person $ defstruct Person (:name :string) (:age :number) (:position :tag)
    p $ Person :name |Chen :age 20 :position :mainland
  ; get the tag name of the struct value
  println $ &struct:get-name p
  ; => :Person
  ; inspect the definition behind a struct value
  println $ struct-definition p
```

读取外部数据时，`&struct:get-name` 可以用来筛选旧存档等 wire 数据中的 Struct 值：先用 `struct?` 守卫，再按短名称选择兼容分支，然后用 `.to-map` 转成 Map，完整检查字段并重建本地类型。短名称只是数据里的标记，不证明来源、名义身份或权限，所以这条路径不能改成对本地类型的 `struct-match`，也不能省略后续的字段检查。

### 定义身份与值相等

顶层 Struct 定义使用完整的 `namespace/definition` 作为名义身份。两个命名空间即使都声明了名为 `Point`、字段完全相同的 Struct，也代表不同类型；`get-name` 返回的短名称不能用作身份判断。普通 `=` 要求两个操作数具有相同静态类型，业务代码应先按类型分支，再比较同类型的值。

Struct 值的相等比较同时检查定义身份、名称、字段名和字段值；字段值按各自的值语义递归比较。重新构造的相等值可以查询原有 Map key，相等值的哈希也必须一致。哈希数值属于各 backend 的内部实现，不用于持久化标识或跨 backend 通信。

热重载不把定义对象的内存地址作为身份：同一个 `namespace/definition` 重新求值后，只要名称、字段布局和值仍相同，旧值和新值仍然相等。修改字段布局或字段值后不再相等。引用已有定义的别名保留原始身份；更新字段和附加 trait 实现也保留定义身份。

没有绑定到顶层定义的局部 Struct 和未经类型恢复的 EDN Struct 数据不自动获得某个同名顶层定义的身份。需要判断来源时，使用 Struct 类型判断，而不是比较打印出来的名字。

### Struct Origin Check

以已知来源的 Struct 定义或实例作为目标时，`&struct:matches?` 检查原始的
`namespace/definition` 和数据合同，而不只比较短名称与字段名。不同模块里的同名同字段
Struct 不会相互匹配；别名、字段更新和附加 trait 实现仍保留原来源。

对非泛型顶层定义的判断成功后，类型推导可以安全读取其声明字段：

```cirru
let
    read-path $ fn (value)
      hint-fn $ Fn Struct (-> String)
      if (&struct:matches? value FsPath) (:value value) (raise |expected-FsPath)
  assert= |data/config.cirru $ read-path $ FsPath :value |data/config.cirru
```

无来源的局部 pattern 保留名称与字段布局的结构匹配，可用于不需要名义类型证明的分支：

```cirru
let
    Cat $ defstruct Cat (:name :string) (:color :tag)
    Dog $ defstruct Dog (:name :string)
    v1 $ Cat :name |Mimi :color :white
  if (&struct:matches? v1 Cat)
    println "|Handle Cat branch"
    println "|Not a Cat"
```

#### 限制

- 局部结构匹配不证明字段类型，泛型定义的匹配也不绑定具体类型参数。
- 反序列化得到的无来源 Struct 不会自动恢复某个同名顶层定义，需经受检 decoder 恢复类型。
- 同一来源经热重载改变字段布局或字段合同后，旧值不再满足新目标的来源判断。

## Polymorphism with Traits

Define a trait with `deftrait`, implement it with `defimpl`, and attach it to a struct with `impl-traits`:

```cirru
let
    BirdTrait $ deftrait BirdTrait
      .show $ Fn T (-> Nil)
    BirdShape $ defstruct BirdShape (:name :string)
    BirdImpl $ defimpl BirdImpl BirdTrait
      .show $ fn (self)
        ; defimpl bodies are reusable before a concrete Struct is attached,
        ; so low-level access is explicit at this dynamic implementation boundary.
        ; Do not copy this form into typed application code.
        println $ &struct:get self :name
    Bird $ impl-traits BirdShape BirdImpl
    b $ Bird :name |Sparrow
  assert-traits b BirdTrait
  b .show
  println $ :name b
```

## Common Use Cases

### Configuration Objects

```cirru
let
    Config $ defstruct Config (:host :string) (:port :number) (:debug :bool)
    config $ Config :host |localhost :port 3000 :debug false
  println $ :port config
  ; => 3000
```

### Domain Models

```cirru
let
    Product $ defstruct Product (:id :string) (:name :string) (:price :number) (:discount :number)
    product $ Product :id |P001 :name |Widget :price 100 :discount 0.9
  println $ * (:price product) (:discount product)
  ; => 90
```

## Type Annotations

```cirru
let
    User $ defstruct User (:name String) (:age Number) (:email String)
    get-user-name $ fn (user)
      hint-fn $ Fn User (-> String)
      :name user
  println $ get-user-name
    User :name |John :age 30 :email |john@example.com

; => John
```

## Automatic Map-to-Struct Rewrite

When a function parameter is typed as a struct in its schema, the preprocessor automatically rewrites hashmap literal (`{}`) arguments to struct construction (`%{}`). This keeps ergonomic literal syntax while retaining the nominal type.

```cirru.no-check
defstruct Point (:x Number) (:y Number)

defn sum-point (p)
  hint-fn $ Fn app.main/Point (-> Number)
  &+ (:x p) (:y p)

; The preprocessor rewrites this hashmap to the expected struct:
assert= 30 $ sum-point
  {} (:x 10) (:y 20)
; Equivalent to:
assert= 30 $ sum-point
  Point :x 10 :y 20
```

Requirements for the rewrite to trigger:

- The function must have a schema with `:args` that references a struct type (for example `'ns/StructName` or `'Struct`)
- The argument at the call site must be a hashmap literal (`{}` with tag keys)
- All keys in the hashmap must be tags (`:field-name`)
- The struct definition must be resolvable at preprocess time

If any condition is not met, the argument is left unchanged (no error is raised). This makes the rewrite safe to use alongside existing code.

## Anonymous Structs

Use `_` as the definition marker when a short-lived struct does not need a named `defstruct` declaration.

### Creating an Anonymous Struct

```cirru
%{} _ (:name |John) (:age 30)

; => $ %{} _ (:age 30) (:name |John)
```

Fields are automatically sorted alphabetically, matching the field ordering of named structs. All keys must be tags, and duplicate keys produce an error.

### Accessing Fields

匿名 Struct 虽然在运行时携带字段集合，但不具备类型化必填字段访问所需的具名声明。应用代码不应借此
绕过字段分析；需要必填字段时，先把值转换或改写为预期的具名 Struct。若数据本来就是显式开放的
`Dynamic`，并且字段允许缺失，则使用普通 `get` 获得 `Option<Dynamic>`。`&struct:get` 只保留给
core/runtime 或有意实现动态边界的可复用 `defimpl`。

下面的代码只演示低层运行时行为；应用代码直接写相同调用仍会触发类型化访问告警。

```cirru.no-check
let
    r $ %{} _ (:x 10) (:y 20)
  println $ &struct:get r :x
  ; => 10
  println $ type-of r
  ; => :struct
```

### Automatic Rewrite to a Named Struct

When an anonymous struct is passed to a function whose parameter is a named struct, the preprocessor can rewrite it to the expected nominal definition:

```cirru.no-check
defstruct Point (:x Number) (:y Number)

defn sum-point (p)
  hint-fn $ Fn app.main/Point (-> Number)
  &+ (:x p) (:y p)

; Anonymous struct rewritten to the expected named struct:
assert= 30 $ sum-point (%{} _ (:x 10) (:y 20))
; Equivalent to:
assert= 30 $ sum-point
  Point :x 10 :y 20
```

该改写与 map 字面量（`{}`、`?{}`）改写成 Struct 的规则相同。字面量未提供的字段和直接构造器一样补齐：有默认值的字段填入默认值，声明为 `Option<T>` 的字段填入 `Option :none`；其他未提供的字段填入 `nil`，因此它们的声明类型必须允许 `nil`，否则类型检查会报错。

### Design Symmetry

The collection type system follows a consistent "precision increasing" pattern:

| Positional (by index)            | Named (by field)                       |
| -------------------------------- | -------------------------------------- |
| `list` (dynamic)                 | `hashmap` (dynamic)                    |
| `%:: _ :tag ...` (anonymous enum) | `%{} _ (:field val)` (anonymous struct) |
| `Enum :tag ...` (named enum)      | `Struct :field val` (named struct)     |

Both anonymous enums and anonymous structs can be rewritten to their named counterparts when function parameter types are known at compile time.

## Performance Notes

- Struct values are immutable — updates create new values
- Field access is O(1) when the struct type is known at preprocess time (compile-time index resolution)
- When the type is unknown, field access falls back to O(log n) binary search over sorted field names
- Use `struct-with` to update multiple fields at once and minimize intermediate allocations

### Type-Directed Optimizations

When the static analysis system knows a value's struct type, the preprocessor rewrites field operations to skip runtime name lookups:

- **Field read** `(:field value)` → `&struct:nth value <index>` — direct index access instead of name search
- **Field update** `&struct:assoc value :field next` → `&struct:assoc-at value <index> next`
- **Batch update** `struct-with value (:f1 v1) (:f2 v2)` → `&struct:with-at value <indexes> <values>`

These rewrites are automatic and transparent. To benefit from them, provide type annotations via `:schema` or `hint-fn` so the preprocessor can resolve struct types.

Struct intentionally has no public `.nth` method: positional field order is not
a cross-backend API contract. Use `(:field value)` or `value.:field`, which
returns the declared field type directly. Only generated, statically checked
access uses internal `&struct:nth`.
