Claude Code Plugins

Community-maintained marketplace

Feedback

How to write Malli schemas for Guardrails function specifications in Clojure/ClojureScript. This skill teaches the schema types available, map schema patterns, and best practices for type-safe function definitions using >defn.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name guardrails
description How to write Malli schemas for Guardrails function specifications in Clojure/ClojureScript. This skill teaches the schema types available, map schema patterns, and best practices for type-safe function definitions using >defn.

You are an expert in writing Malli schemas for Guardrails function specifications. Guardrails enables runtime validation of function arguments and return values using Malli schemas, providing type safety without sacrificing Clojure's dynamic nature.

Core Concepts

Guardrails uses >defn to define functions with Malli type specifications. The specification follows a "gspec" syntax that declares argument types and return types:

(ns your.namespace
  (:require
    [com.fulcrologic.guardrails.malli.core :refer [=> >defn]]))

(>defn function-name
  "Docstring"
  [arg1 arg2]
  [schema1 schema2 => return-schema]
  (function-body))

Key operators:

  • => separates input schemas from output schema
  • ? macro creates nullable schemas: (? :string) = [:maybe :string]
  • | describes side effects (rarely used)

Primitive Type Schemas

Use these keyword schemas for basic types:

Schema Matches Properties
:any Any value -
:some Any non-nil value -
:nil Only nil -
:string Strings :min, :max for length
:int Integers :min, :max for range
:float Float numbers -
:double Double numbers :min, :max, :gen/NaN?, :gen/infinite?
:boolean true/false -
:keyword Any keyword -
:qualified-keyword Namespaced keywords :namespace property
:symbol Any symbol -
:qualified-symbol Namespaced symbols :namespace property
:uuid UUIDs -

Examples:

(>defn greet
  "Returns a greeting string."
  [name]
  [:string => :string]
  (str "Hello, " name "!"))

(>defn add
  "Adds two integers."
  [a b]
  [:int :int => :int]
  (+ a b))

(>defn calculate-ratio
  "Returns a ratio as a double."
  [numerator denominator]
  [:number :number => :double]
  (double (/ numerator denominator)))

Predicate Schemas

Standard Clojure predicates work as schemas:

;; Number predicates
number? integer? int? pos-int? neg-int? nat-int?
pos? neg? float? double? zero?

;; Type predicates
boolean? string? keyword? symbol? uuid? uri? inst?

;; Qualified identifier predicates
ident? simple-ident? qualified-ident?
simple-keyword? qualified-keyword?
simple-symbol? qualified-symbol?

;; Collection predicates
map? vector? list? seq? set? coll?
seqable? indexed? associative? sequential?

;; Function predicates
ifn? fn?

;; Other
any? some? nil? false? true? char?

When to use predicates vs keyword schemas:

  • Use :string when you need properties like :min/:max
  • Use string? for simple type checking
  • Keyword schemas (:int, :string) are preferred for clarity

Collection Schemas

Vector Schema

For homogeneous vectors:

[:vector :int]           ; Vector of integers
[:vector {:min 1} :int]  ; Non-empty vector of integers
[:vector {:min 1 :max 10} :string]  ; 1-10 strings

Set Schema

For homogeneous sets:

[:set :keyword]          ; Set of keywords
[:set {:min 1} :string]  ; Non-empty set of strings

Sequential Schema

For any sequential collection:

[:sequential :int]       ; List, vector, or seq of ints

Tuple Schema

For fixed-length heterogeneous vectors:

[:tuple :double :double]           ; [lat, lon] pair
[:tuple :keyword :string :int]     ; [:type "name" 42]

Map Schemas (Critical Section)

Maps are the most important schema type for domain modeling.

Basic Map Schema

[:map
 [:key1 schema1]
 [:key2 schema2]]

Each entry is [key-name schema] or [key-name properties schema].

Required vs Optional Keys

[:map
 [:name :string]                          ; Required
 [:email :string]                         ; Required
 [:nickname {:optional true} :string]]    ; Optional

Closed Maps

By default, maps allow extra keys. Close them to reject extras:

[:map {:closed true}
 [:x :int]
 [:y :int]]

;; Valid: {:x 1 :y 2}
;; Invalid: {:x 1 :y 2 :z 3}

Nested Maps

[:map
 [:user [:map
         [:id :uuid]
         [:name :string]
         [:email :string]]]
 [:order [:map
          [:id :int]
          [:total :number]]]]

Qualified Keywords in Maps

For spec-like decomplected maps, use qualified keywords with a registry:

;; Define schemas in registry
(>def :user/id :uuid)
(>def :user/name :string)
(>def :user/email [:string {:min 5}])

;; Use in map - key is both the map key and schema reference
[:map
 :user/id      ; Key ::user/id with schema from registry
 :user/name
 :user/email]

;; Data shape: {:user/id #uuid"..." :user/name "Bob" :user/email "bob@example.com"}

Map-of Schema

For homogeneous key-value maps:

[:map-of :string :int]           ; {"a" 1 "b" 2}
[:map-of :keyword [:map [:x :int] [:y :int]]]  ; {:point1 {:x 1 :y 2}}

Composite Schemas

Maybe (Nullable)

Use the ? macro for nullable values:

(>defn find-user
  [id users]
  [:uuid [:vector :map] => (? [:map [:id :uuid] [:name :string]])]
  (first (filter #(= id (:id %)) users)))

Or use :maybe directly:

[:maybe :string]  ; String or nil

Enum

For fixed set of values:

[:enum :pending :active :completed]
[:enum "small" "medium" "large"]
[:enum nil 1 2 3]  ; Note: nil requires explicit nil as first value

And (Intersection)

All schemas must match:

[:and :int [:> 0] [:< 100]]  ; Integer between 1-99
[:and :string [:fn #(> (count %) 3)]]  ; String longer than 3 chars

Or (Union)

Any schema may match:

[:or :string :int]           ; String or integer
[:or :keyword :string :int]  ; Any of these

Orn (Named Union)

Named alternatives for better error messages:

[:orn
 [:by-id :uuid]
 [:by-email :string]
 [:by-name [:tuple :string :string]]]

Multi (Dispatch)

For polymorphic types:

[:multi {:dispatch :type}
 [:user [:map [:type [:= :user]] [:name :string]]]
 [:admin [:map [:type [:= :admin]] [:permissions [:set :keyword]]]]]

Comparator Schemas

[:> 0]        ; Greater than 0
[:>= 0]       ; Greater than or equal to 0
[:< 100]      ; Less than 100
[:<= 100]     ; Less than or equal to 100
[:= 42]       ; Exactly 42
[:not= 0]     ; Not zero

Custom Predicate Schemas

Use :fn for arbitrary validation:

[:fn pos?]                                    ; Positive number
[:fn {:error/message "must be even"} even?]   ; Even with custom error
[:fn (fn [{:keys [x y]}] (> x y))]            ; x must be greater than y

Sequence Regex Schemas

For validating sequences with patterns:

;; Concatenation
[:cat :keyword :string :int]     ; [:foo "bar" 42]

;; Named concatenation
[:catn
 [:op :keyword]
 [:name :string]
 [:value :int]]

;; Repetition
[:* :int]                        ; Zero or more ints
[:+ :int]                        ; One or more ints
[:? :int]                        ; Zero or one int
[:repeat {:min 2 :max 4} :int]   ; 2-4 ints

Registry for Reusable Schemas

Define reusable schemas with >def:

;; Simple type aliases
(>def :user/id :uuid)
(>def :user/email [:string {:min 5}])
(>def :money/amount [:double {:min 0}])

;; Complex domain schemas
(>def :domain/user
  [:map
   [:id :user/id]
   [:email :user/email]
   [:name :string]
   [:created-at inst?]])

(>def :domain/order
  [:map
   [:id :uuid]
   [:user-id :user/id]
   [:items [:vector [:map
                     [:product-id :uuid]
                     [:quantity pos-int?]
                     [:price :money/amount]]]]
   [:total :money/amount]
   [:status [:enum :pending :paid :shipped :delivered]]])

Then use in functions:

(>defn create-order
  [user items]
  [:domain/user [:vector :map] => :domain/order]
  ...)

Best Practices

1. Prefer Keyword Schemas Over Predicates

;; Preferred
[:int :string => :boolean]

;; Less clear
[int? string? => boolean?]

2. Use :number for Generic Numeric Input

When a function works with any number:

(>defn square
  [x]
  [:number => :number]  ; Not :int or :double
  (* x x))

3. Create Registry Entries for Domain Types

Don't repeat complex schemas:

;; Bad - repeated everywhere
(>defn process-user
  [user]
  [[:map [:id :uuid] [:name :string] [:email :string]] => :boolean]
  ...)

;; Good - reusable
(>def :domain/user [:map [:id :uuid] [:name :string] [:email :string]])

(>defn process-user
  [user]
  [:domain/user => :boolean]
  ...)

4. Use Qualified Keywords for Cross-Namespace Consistency

(>def :order/id :uuid)
(>def :order/status [:enum :pending :paid :shipped])
(>def :order/total [:double {:min 0}])

5. Mark Optional Keys Explicitly

[:map
 [:required-field :string]
 [:optional-field {:optional true} :string]]

6. Use (?) for Nullable Return Values

(>defn find-by-id
  [id items]
  [:uuid [:vector :map] => (? :map)]  ; May return nil
  (first (filter #(= id (:id %)) items)))

7. Avoid Over-Specification

Don't constrain more than necessary:

;; Over-specified - breaks if data shape changes
[:map {:closed true}
 [:x [:int {:min 0 :max 1000}]]
 [:y [:int {:min 0 :max 1000}]]]

;; Appropriate for most cases
[:map
 [:x :int]
 [:y :int]]

8. Map Schema Depth: Specify Only Local Requirements

This is the most important principle for map schemas.

A function's map schema should describe what that function locally needs, not everything the map might contain. The temptation is to make schemas "too deep" by specifying all nested structure, but this creates maintenance nightmares.

The Principle:

  1. Specify keys the function directly accesses
  2. Never go deeper - called functions may change their requirements
  3. Assume functions might require less in the future, so transitive assertions should be sparse
;; BAD - Too deep, specifies everything
(>defn process-person
  [person]
  [[:map
    [:id :uuid]
    [:name :string]
    [:email :string]
    [:address [:map                    ; We don't use address!
               [:street :string]
               [:city :string]
               [:zip :string]]]
    [:preferences [:map                ; We don't use preferences!
                   [:theme :keyword]
                   [:notifications :boolean]]]]
   => :boolean]
  ;; But we only use :id and :name...
  (log-action (:id person) (:name person))
  true)

;; GOOD - Specifies only what we locally need
(>defn process-person
  [person]
  [[:map [:id :uuid] [:name :string]] => :boolean]
  (log-action (:id person) (:name person))
  true)

When passing maps to other functions:

;; If f calls g, h, and i, and all require :person/id...
;; Might include that ONE key as a hint to callers, but don't go deeper

(>defn f
  "Processes person through multiple handlers."
  [person]
  [[:map [:person/id :uuid]] => :any]  ; Hint: callees need :person/id (and probably won't change that)
  (g person)
  (h person)
  (i person))

;; DON'T try to union all requirements of g, h, i - that's brittle
;; If g changes to need :person/email, you'd have to update f's schema

Why this matters:

  • Refactoring becomes easier - change a called function without updating all callers' schemas
  • Functions may require less over time as they're simplified
  • Deep schemas create false coupling between layers

For domain types in the registry:

;; Registry types CAN be complete - they represent the full domain concept
(>def :domain/person
  [:map
   [:id :uuid]
   [:name :string]
   [:email :string]
   [:address [:map [:street :string] [:city :string]]]])

;; But function schemas should still be minimal
(>defn get-person-greeting
  [person]
  [[:map [:name :string]] => :string]  ; Only needs :name
  (str "Hello, " (:name person) "!"))

;; Callers with a full :domain/person can pass it - the schema is open
;; The function documents it only needs :name

9. Document Complex Schemas

(>def :api/response
  "Standard API response wrapper.
   :data contains the payload, :meta has pagination info."
  [:map
   [:data :any]
   [:meta {:optional true} [:map
                            [:page pos-int?]
                            [:per-page pos-int?]
                            [:total nat-int?]]]])

Complete Example

(ns myapp.orders
  (:require
    [com.fulcrologic.guardrails.malli.core :refer [=> >def >defn ?]]))

;; Registry definitions
(>def :order/id :uuid)
(>def :order/status [:enum :pending :paid :shipped :delivered :cancelled])
(>def :money/amount [:double {:min 0}])

(>def :order/item
  [:map
   [:item/quantity pos-int?]
   [:product/id :uuid]
   [:product/name :string]
   [:product/unit-price :money/amount]])

(>def :domain/order
  [:map
   :order/id
   :order/status 
   [:order/customer-email :string]
   [:order/items [:vector :order/item]]
   [:order/total :money/amount]
   [:order/notes {:optional true} :string]])

;; Pure functions
(>defn calculate-item-total
  "Calculate total for a single line item."
  [item]
  ;; Explicit requirements, not the (temptation) of :order/item!!!
  [[:map
    [:item/quantity pos-int?]
    [:product/unit-price :money/amount]] => :money/amount]
  (* (:item/quantity item) (:product/unit-price item)))

(>defn calculate-order-total
  "Sum all item totals."
  [items]
  ;; This function only cares that they are maps
  [[:vector map?] => :money/amount]
  (reduce + 0 (map calculate-item-total items)))

(>defn order-can-ship?
  "Check if order is ready to ship."
  [order]
  ;; Note that order/items isn' needed because false is a possible outcome
  [[:map :order/status] => :boolean]
  (and (= :paid (:order/status order))
    (seq (:order/items order))))

(>defn find-order-by-id
  "Find order by ID, returns nil if not found."
  [order-id orders]
  ;; In this case we're working against domain items, so we could be enforcing the fact that what we get/return must
  ;; be complete.
  [:order/id [:vector :domain/order] => (? :domain/order)]
  (first (filter #(= order-id (:order/id %)) orders)))

;; Multi-arity function
(>defn create-order
  "Create a new order. Can provide custom ID or generate one."
  ([customer-email items]
   [:string [:vector :order/item] => :domain/order]
   (create-order (random-uuid) customer-email items))
  ([id customer-email items]
   [:order/id :string [:vector :order/item] => :domain/order]
   {:id             id
    :customer-email customer-email
    :items          items
    :total          (calculate-order-total items)
    :status         :pending}))

(>defn update-order-status
  "Update order status with validation."
  [order new-status]
  [:domain/order :order/status => :domain/order]
  (assoc order :status new-status))

Function Schema Patterns

Side-Effect Functions

For functions that perform side effects, return :nil or a result type:

(>defn save-order!
  "Persist order to database."
  [db order]
  [:any :domain/order => :nil]
  (db/insert! db :orders order)
  nil)

(>defn send-notification!
  "Send email notification, returns success boolean."
  [email message]
  [:string :string => :boolean]
  (try
    (email/send! email message)
    true
    (catch Exception _ false)))

Functions Returning Results

For functions that may fail, use :or or :orn:

(>def :result/success [:map [:status [:= :success]] [:data :any]])
(>def :result/error [:map [:status [:= :error]] [:message :string]])
(>def :result/either [:or :result/success :result/error])

(>defn process-payment
  [order payment-info]
  [:domain/order :map => :result/either]
  (try
    {:status :success :data (charge! order payment-info)}
    (catch Exception e
      {:status :error :message (ex-message e)})))

Schema Error Messages

Add custom error messages for better debugging:

[:string {:error/message "must be a valid email"
          :error/fn      (fn [{:keys [value]} _]
                           (str "'" value "' is not a valid email"))}]

[:fn {:error/message "password must be at least 8 characters"}
 #(>= (count %) 8)]

Testing with Guardrails

Operating Modes

Guardrails has three operating modes:

Mode Runtime Validation Externs Registry Use Case
:runtime Default. Type checking only
:pro IDE tooling, no runtime overhead
:all Required for proof system

Set the mode via JVM property:

-Dguardrails.mode=:all

IMPORTANT: For the fulcro-spec proof system (transitive coverage, signatures), you MUST use :all mode. Without it, the externs registry won't be populated and all functions will appear as leaf functions with no callees.

Test Configuration

;; deps.edn alias for tests with proof system support
:test {:jvm-opts    ["-Dguardrails.enabled" "-Dguardrails.mode=:all"]
       :extra-paths ["src/test"]}

In test mode, guardrails should throw on validation errors:

;; Configuration for tests (throw on errors)
{:throw?          true
 :guardrails/mcps 20}  ; Max calls per second for validation

;; Configuration for development REPL (warn but don't throw)
{:throw?          false
 :guardrails/mcps 10}

This enables validation to catch type errors during testing while not breaking REPL-driven development.