Beginner

Variables and Types in Clojure

Learn about values, bindings, and data types in Clojure — a functional Lisp where immutability is the default and data structures are first-class citizens

Clojure approaches “variables” differently than most languages. As a functional Lisp, Clojure emphasizes immutable values and bindings rather than mutable variables. Understanding this distinction is fundamental — in Clojure, you don’t change values, you create new ones from old ones.

Clojure is dynamically typed, meaning types are associated with values rather than names. A binding can hold any type of value, but the value itself always knows its type. The language also features a rich set of built-in data structures — lists, vectors, maps, and sets — that are all persistent (immutable) by design.

In this tutorial, you’ll learn how Clojure’s def and let forms create named bindings, explore Clojure’s core scalar types, see how its persistent collections work, and understand how type coercion and checking work in a dynamically typed Lisp.

Scalar Types and def Bindings

The def form creates a named binding in the current namespace. Unlike a variable in imperative languages, a def binding points to a value — and Clojure’s values don’t change.

Create a file named variables_basic.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
;; Integers (arbitrary precision with Long by default)
(def my-age 42)
(def big-number 10000000000000)

;; Floating-point (Java double)
(def pi 3.14159)
(def exact-pi 22/7)  ; Clojure ratios — exact rational arithmetic!

;; Strings
(def language-name "Clojure")
(def greeting (str "Hello from " language-name "!"))

;; Booleans
(def is-functional true)
(def is-mutable false)

;; nil — Clojure's null value
(def nothing nil)

;; Characters
(def first-char \C)

;; Keywords — lightweight identifiers, often used as map keys
(def status :active)
(def role :admin)

;; Symbols (quoted so they aren't evaluated as variable references)
(def my-symbol 'clojure)

(println "Age:" my-age)
(println "Big number:" big-number)
(println "Pi:" pi)
(println "Exact pi (ratio):" exact-pi)
(println "Greeting:" greeting)
(println "Functional?" is-functional)
(println "Mutable?" is-mutable)
(println "Nothing:" nothing)
(println "First char:" first-char)
(println "Status keyword:" status)
(println "Symbol:" my-symbol)

Running with Docker

1
2
3
4
5
# Pull the official image
docker pull clojure:latest

# Run the basic types example
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure variables_basic.clj

Expected Output

Age: 42
Big number: 10000000000000
Pi: 3.14159
Exact pi (ratio): 22/7
Greeting: Hello from Clojure!
Functional? true
Mutable? false
Nothing: nil
First char: C
Status keyword: :active
Symbol: clojure

Local Bindings with let

While def creates namespace-level bindings, let creates local bindings scoped to a block. This is the idiomatic way to name intermediate values in Clojure.

Create a file named variables_let.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
;; let creates a local scope with name/value pairs
(let [x 10
      y 20
      sum (+ x y)
      product (* x y)]
  (println "x:" x)
  (println "y:" y)
  (println "sum:" sum)
  (println "product:" product))

;; let bindings are sequential — later bindings can use earlier ones
(let [radius 5.0
      area (* Math/PI radius radius)
      circumference (* 2 Math/PI radius)]
  (println "Radius:" radius)
  (println "Area:" (format "%.4f" area))
  (println "Circumference:" (format "%.4f" circumference)))

;; let is an expression — it returns the value of the last form
(def circle-area
  (let [r 7]
    (* Math/PI r r)))

(println "Circle area (r=7):" (format "%.4f" circle-area))
1
2
docker pull clojure:latest
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure variables_let.clj

Expected Output

x: 10
y: 20
sum: 30
product: 200
Radius: 5.0
Area: 78.5398
Circumference: 31.4159
Circle area (r=7): 153.9380

Clojure’s Persistent Collections

Clojure’s built-in collections are immutable. “Modifying” a collection returns a new collection that shares structure with the original — this is efficient and safe.

Create a file named variables_collections.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
;; Vectors — ordered, indexed collections (most common)
(def fruits ["apple" "banana" "cherry"])
(def numbers [1 2 3 4 5])

;; Lists — linked lists, good for sequential processing
(def my-list '(10 20 30 40))

;; Maps — key/value pairs (keywords as keys is idiomatic)
(def person {:name "Alice"
             :age 30
             :role :developer})

;; Sets — unique values, unordered
(def languages #{:clojure :haskell :erlang :scala})

;; Accessing elements
(println "First fruit:" (first fruits))
(println "Second fruit:" (second fruits))
(println "Third fruit:" (nth fruits 2))
(println "Last fruit:" (last fruits))

;; Accessing map values
(println "Name:" (:name person))
(println "Age:" (:age person))
(println "Role:" (get person :role))

;; "Adding" to a vector returns a new vector
(def more-fruits (conj fruits "date"))
(println "Original fruits:" fruits)
(println "Extended fruits:" more-fruits)

;; "Adding" to a map returns a new map
(def updated-person (assoc person :city "London"))
(println "Original person:" person)
(println "Updated person:" updated-person)

;; Sets — check membership
(println "Clojure in set?" (contains? languages :clojure))
(println "Python in set?" (contains? languages :python))

;; Count works on all collections
(println "Fruit count:" (count fruits))
(println "Map entries:" (count person))
1
2
docker pull clojure:latest
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure variables_collections.clj

Expected Output

First fruit: apple
Second fruit: banana
Third fruit: cherry
Last fruit: cherry
Name: Alice
Age: 30
Role: :developer
Original fruits: [apple banana cherry]
Extended fruits: [apple banana cherry date]
Original person: {:name Alice, :age 30, :role :developer}
Updated person: {:name Alice, :age 30, :role :developer, :city London}
Clojure in set? true
Python in set? false
Fruit count: 3
Map entries: 3

Type Checking and Conversion

Clojure’s dynamic type system means values carry type information at runtime. You can query and convert types explicitly.

Create a file named variables_types.clj:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
;; type returns the Java class of a value
(println "Type of 42:" (type 42))
(println "Type of 3.14:" (type 3.14))
(println "Type of 22/7:" (type 22/7))
(println "Type of \"hello\":" (type "hello"))
(println "Type of true:" (type true))
(println "Type of nil:" (type nil))
(println "Type of :keyword:" (type :keyword))
(println "Type of [1 2 3]:" (type [1 2 3]))
(println "Type of {\"a\" 1}:" (type {"a" 1}))

;; Predicate functions for type checking
(println)
(println "integer? 42:" (integer? 42))
(println "float? 3.14:" (float? 3.14))
(println "ratio? 22/7:" (ratio? 22/7))
(println "string? \"hi\":" (string? "hi"))
(println "keyword? :x:" (keyword? :x))
(println "nil? nil:" (nil? nil))
(println "nil? false:" (nil? false))
(println "boolean? true:" (boolean? true))
(println "vector? [1 2]:" (vector? [1 2]))
(println "map? {:a 1}:" (map? {:a 1}))
(println "seq? '(1 2):" (seq? '(1 2)))

;; Type conversions
(println)
(println "int->float:" (float 42))
(println "float->int:" (int 3.9))       ; truncates
(println "string->int:" (Integer/parseInt "123"))
(println "int->string:" (str 456))
(println "keyword->string:" (name :hello))
(println "string->keyword:" (keyword "world"))
(println "number->ratio:" (rationalize 0.1))  ; exact rational
1
2
docker pull clojure:latest
docker run --rm -v $(pwd):/app -w /app clojure:latest clojure variables_types.clj

Expected Output

Type of 42: class java.lang.Long
Type of 3.14: class java.lang.Double
Type of 22/7: class clojure.lang.Ratio
Type of "hello": class java.lang.String
Type of true: class java.lang.Boolean
Type of nil: nil
Type of :keyword: class clojure.lang.Keyword
Type of [1 2 3]: class clojure.lang.PersistentVector
Type of {"a" 1}: class clojure.lang.PersistentArrayMap

integer? 42: true
float? 3.14: false
ratio? 22/7: true
string? "hi": true
keyword? :x: true
nil? nil: true
nil? false: false
boolean? true: true
vector? [1 2]: true
map? {:a 1}: true
seq? '(1 2): true

int->float: 42.0
float->int: 3
string->int: 123
int->string: 456
keyword->string: hello
string->keyword: :world
number->ratio: 3602879701896397/36028797018963968

Key Concepts

  • Values, not variablesdef creates an immutable binding to a value; the underlying value never changes. Use let for local, scoped bindings within a function or block.
  • Everything is an expressionlet, if, do, and all other Clojure forms return values, making composition natural.
  • Persistent collections — Vectors, maps, lists, and sets are immutable; “modification” operations like conj and assoc return new collections, leaving the original untouched.
  • Keywords as identifiers — Keywords (:name, :age) are first-class values, evaluated to themselves, and are the idiomatic choice for map keys.
  • Dynamic, strong typing — Clojure doesn’t require type annotations but will not silently coerce incompatible types (e.g., adding a string to a number is an error, not a coercion).
  • Ratio type — Clojure natively supports exact rational arithmetic with the Ratio type (22/7 stays as 22/7, not 3.142857...).
  • nil vs falsenil and false are the only falsy values; everything else (including 0, "", and []) is truthy — different from many languages.
  • JVM types underneath — Clojure scalars map to Java types (Long, Double, String, Boolean), which enables seamless Java interop.

Running Today

All examples can be run using Docker:

docker pull clojure:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining