◆ Chapter 03
The core language: values, types, and control flow
Values, types, truthiness and control flow — the everyday syntax you read in every codebase, with the JS gotchas called out.
~2,409 words · chapter 3 of 15
You already think in values and expressions. Python's atoms are close to JavaScript's, but the rules underneath differ in ways that bite. This chapter builds the foundation you need to read GenAI code — the parsing, prompt-building, and response-guarding that fills every agent codebase — and judge it.
3.1 Names, not variables
Python has no let, const, or var. You just assign:
model = "claude-opus-4" # a name bound to a string object
temperature = 0.7
model = "claude-haiku" # rebinding the same name — totally fine
A name is a label pointing at an object. Assignment never copies; it points. This is exactly JS reference semantics, but applied uniformly — there is no primitive/object split.
a = [1, 2, 3]
b = a # b points at the SAME list
b.append(4)
print(a) # => [1, 2, 3, 4] ← a changed too
Multiple and tuple assignment is idiomatic and everywhere in real code:
x, y = 10, 20
x, y = y, x # swap, no temp var => x=20, y=10
role, content = ("user", "Hi") # unpacking a tuple
first, *rest = [1, 2, 3, 4] # => first=1, rest=[2, 3, 4]
Everything is an object — ints, functions, classes, modules, None. Every value has a type and methods. (42).bit_length() works; functions can be passed around and stored in dicts (the backbone of tool-dispatch tables in agents).
JS → Python: No variable keyword — bare
name = value. There is no block scoping ({}blocks don't scope; functions do). No hoisting; a name exists only after it's assigned.consthas no equivalent — immutability is a property of the object (see §3.4), not the binding.
Reading & judging: The classic bug is a shared mutable default (
def f(items=[])). Since the list is created once and shared across calls, it accumulates state. Good code usesdef f(items=None): items = items or []. Flag any mutable default argument on sight.
3.2 The scalar types
| Type | Example | Notes |
|---|---|---|
int |
42, 10**100 |
arbitrary precision — never overflows |
float |
0.7, 1e-4 |
64-bit IEEE 754, same as JS numbers |
bool |
True, False |
Capitalized. A subclass of int |
str |
"hi" |
Immutable Unicode text |
NoneType |
None |
The one-and-only "nothing" value |
print(2 ** 200) # => 1606938044258990275541962092341162602522202993782792835301376
print(type(True)) # => <class 'bool'>
print(True + True) # => 2 ← bool IS an int
print(int("128007")) # => 128007 (token counts arrive as strings from APIs)
None is Python's only null. There is no undefined. A missing dict key, an unset attribute, a function with no return — all yield None. This single-null design removes an entire class of JS confusion.
def maybe_tool():
pass # no return statement
print(maybe_tool()) # => None
JS → Python: JS has both
nullandundefined; Python has onlyNone.typeof x === "undefined"checks vanish.boolbeing anintsubclass meanssum([True, False, True])counts truthy items — a real idiom for counting matches over a list.
3.3 Numbers & arithmetic
7 / 2 # => 3.5 true division — ALWAYS returns float
7 // 2 # => 3 floor division (rounds toward -inf)
-7 // 2 # => -4 (not -3!)
7 % 3 # => 1 modulo
2 ** 10 # => 1024 power (JS uses ** too, but has no // or true-division split)
The / vs // split is a top JS-dev trap: in JS 7/2 is 3.5 and you reach for Math.floor. In Python / is always float division and // is the floor operator. Int/float mixing auto-coerces to float: 3 + 0.5 => 3.5.
The sharpest contrast: Python does not coerce strings and numbers.
"tokens: " + 128 # => TypeError: can only concatenate str (not "int") to str
"tokens: " + str(128) # => 'tokens: 128' ← convert explicitly
f"tokens: {128}" # => 'tokens: 128' ← f-string is cleaner
In JS, "tokens: " + 128 silently gives "tokens: 128". Python raises. This is a feature — it catches the "I meant to format that" bug at runtime instead of shipping garbage into a prompt.
Reading & judging: Seeing
str(x)sprinkled everywhere is fine and correct. A red flag isint(user_value)with no surrounding error handling — a non-numeric string raisesValueError. Boundary-parsing of API/user data should be guarded (Chapter on error handling).
3.4 Strings in depth
Strings are immutable — every "modification" returns a new string.
name = "claude"
name.upper() # => 'CLAUDE' (new string)
print(name) # => 'claude' (original untouched)
Quotes: single and double are identical ('x' == "x"). Triple quotes span lines — the workhorse for system prompts:
SYSTEM_PROMPT = """You are a careful assistant.
Answer only from the provided context.
If unsure, say "I don't know"."""
f-strings are the single most important string feature you'll read. Prefix f, embed expressions in {}:
model, temp, cost = "opus", 0.7, 1234.5
f"Using {model} at temp={temp}" # => 'Using opus at temp=0.7'
f"{temp * 2}" # => '1.4' (any expression)
f"{cost:.2f}" # => '1234.50' (2 decimal places)
f"{cost:,.2f}" # => '1,234.50' (thousands separator)
f"{0.8734:.1%}" # => '87.3%' (percent)
f"{temp=}" # => 'temp=0.7' (self-documenting — debug gold)
The = specifier (f"{temp=}") prints both the expression and its value — invaluable when reading logging code. Format specs after : control width, precision, and grouping.
Essential methods (all return new values):
" hi ".strip() # => 'hi' (trim whitespace)
"a,b,c".split(",") # => ['a', 'b', 'c']
"-".join(["a", "b", "c"]) # => 'a-b-c' (note: SEPARATOR.join(list))
"gpt-4".replace("gpt", "claude") # => 'claude-4'
"Bearer sk-123".startswith("Bearer") # => True
"CLAUDE".lower() # => 'claude'
"a,b,,c".split(",") # => ['a', 'b', '', 'c'] (empty fields kept)
Note .join is called on the separator, not the list — a frequent JS-dev stumble (JS is arr.join("-")).
Raw strings (r"...") disable backslash escapes — vital for regex, Windows paths, and prompts containing literal backslashes:
r"\d+\.\d+" # => '\\d+\\.\\d+' — regex stays literal, no escaping doubled
"C:\new" # => 'C:' + newline! \n interpreted
r"C:\new" # => 'C:\\new' safe
Reading & judging: Good code uses f-strings for interpolation. Seeing
"text " + str(x) + " more"is sloppy but works. A genuine red flag:%-formatting or.format()for new code (legacy, error-prone). Another: building prompts with naive+concatenation across many lines instead of a triple-quoted template — harder to audit for injection.
3.5 Booleans and truthiness
Any value can be tested for truth. The falsy values are a fixed, small set:
False, None, 0, 0.0, "", [], {}, set(), () ← all falsy
Everything else is truthy. This drives the ubiquitous LLM guard pattern:
if not response: # empty string, None, or empty list all caught
raise ValueError("empty model response")
and / or / not are the operators (no && / || / !). Crucially, and/or return an operand, not a bool, and short-circuit:
"" or "default" # => 'default' (first truthy, or the last)
"gpt" or "default" # => 'gpt'
temperature or 0.7 # => 0.7 if temperature is 0/None/"" ← common default idiom
0 and 1/0 # => 0 (short-circuits, never divides)
The x = a or default idiom is everywhere in config code. But beware: it treats any falsy value as "missing". If temperature = 0.0 is a valid setting, temperature or 0.7 wrongly replaces it with 0.7. The correct guard for "was this actually provided?" is x if x is not None else default.
JS → Python: Truthiness diverges sharply. In JS,
[]and{}are truthy; in Python they are falsy. In JS,"0"and" "are truthy; in Python they're truthy too (non-empty strings) — but0the number is falsy in both.NaNis falsy in JS; Python'sfloat('nan')is truthy. Don't port truthiness intuition blindly.
3.6 is vs ==
== tests equality of value. is tests identity — same object in memory.
a = [1, 2]
b = [1, 2]
a == b # => True (same contents)
a is b # => False (different objects)
The one place is is mandatory: checking for None. None is a singleton, so identity is the correct, fast, unambiguous test:
if config is None: # ✅ correct
config = {}
if config == None: # ⚠️ works but wrong style; can be fooled by weird __eq__
...
The gotcha: small integers (−5 to 256) and short strings are interned (reused), so is may accidentally work:
x = 256; y = 256
x is y # => True (interned)
x = 257; y = 257
x is y # => False (not interned — DON'T rely on this)
Reading & judging:
x is None/x is not None= correct and idiomatic.x == None= code smell. Usingisto compare integers, strings, or any value (count is 0,name is "user") is a bug waiting to happen — it works by luck of interning and fails silently for larger values. Flag every value-comparison done withis.
3.7 Comparisons and chaining
Python lets you chain comparisons the way math does:
0 <= temperature <= 1 # => reads naturally; each term evaluated once
0 <= x < 10 # => True if x in [0, 10)
low < value < high # bounds check in one expression
This is a real readability win over JS's 0 <= x && x < 10. Equality (==) works across types safely — 1 == 1.0 is True, 1 == "1" is False (no coercion, unlike JS ==; Python has no === because it never needed one).
3.8 Control flow
Blocks are defined by indentation and a colon, not braces. Four spaces per level, consistently — mixing tabs and spaces is a syntax error.
score = 0.82
if score > 0.9:
tier = "high"
elif score > 0.5: # "else if" is spelled elif
tier = "medium"
else:
tier = "low"
There is no ?: ternary. Instead, a value-returning conditional expression reads left-to-right:
label = "pass" if score >= 0.5 else "fail"
Loops iterate values, not indices — the biggest JS-dev adjustment:
messages = ["hi", "how are you", "bye"]
for msg in messages: # msg IS the value, not an index
print(msg)
for i, msg in enumerate(messages): # need the index too? use enumerate
print(i, msg) # => 0 hi / 1 how are you / 2 bye
for i in range(3): # range(3) => 0,1,2 (stop-exclusive)
print(i)
for a, b in zip(["x", "y"], [1, 2]): # pair up two lists
print(a, b) # => x 1 / y 2
range(start, stop, step) is stop-exclusive (range(1, 4) is 1,2,3). while, break, and continue behave as in JS. Python adds a for/while ... else — the else runs only if the loop finished without break (useful for search loops):
for tool in tools:
if tool.name == wanted:
break
else:
raise LookupError(f"no tool named {wanted}") # runs only if never broke
pass is the explicit no-op — a placeholder where a statement is syntactically required:
try:
risky()
except TimeoutError:
pass # deliberately ignore (be sure that's intended!)
Structural pattern matching (match/case, Python 3.10+) is modern and increasingly common for dispatching on message roles or tool payloads:
def handle(event):
match event:
case {"type": "text", "text": body}: # dict pattern, binds body
return body
case {"type": "tool_use", "name": name}:
return f"calling {name}"
case {"type": "error", "code": int() as code} if code >= 500:
return "server error, retry" # guard with if
case _: # _ is the wildcard default
return "unknown"
print(handle({"type": "text", "text": "hi"})) # => 'hi'
case _ is the catch-all. Patterns can destructure dicts, lists, and classes, and bind names as they match — far more powerful than a switch.
JS → Python:
switch→match, but with destructuring.for (const x of arr)→for x in arr.for (let i=0; i<n; i++)→for i in range(n).arr.forEach((v,i)=>…)→for i, v in enumerate(arr). There is no C-stylefor(;;)— you always iterate over something.
JS → Python operator & keyword cheat sheet
| Concept | JavaScript | Python |
|---|---|---|
| Logical and/or/not | && || ! |
and or not |
| Strict equality | === |
== (no coercion; no === needed) |
| Identity | (none) | is / is not |
| Ternary | c ? a : b |
a if c else b |
| Null check | x == null |
x is None |
| Default value | x ?? d / x || d |
x if x is not None else d / x or d |
| Floor divide | Math.floor(a/b) |
a // b |
| Power | a ** b |
a ** b |
| Type check | typeof x |
type(x) / isinstance(x, T) |
| String→number | Number(s) |
int(s) / float(s) |
| Loop over values | for..of |
for x in seq |
| Loop with index | .forEach((v,i)=>) |
enumerate(seq) |
| Switch | switch |
match/case |
| No-op | ; |
pass |
Reading & judging (control flow): Four recurring bugs to hunt for. (1) Index-iteration hangover —
for i in range(len(items)): items[i]…when a plainfor item in itemswould do; verbose and off-by-one-prone. (2) Mutable/immutable confusion — code that "modifies" a string in place (s.replace(...)on its own line, result discarded) is a no-op, because strings are immutable and.replacereturns a new string. (3)==vsis—if status is "done"is a latent bug. (4) Truthiness on containers —if len(items) > 0is fine but noisy;if itemsis idiomatic. The real danger isif value or defaultswallowing a legitimate0or""— verify the author didn't meanif value is None.
Try it: Run
x = 0; print(x or "fallback")thenprint(x if x is not None else "fallback"). Watch the first printfallbackand the second print0— that gap is a genuine production bug in config-loading code, and now you can spot it in a review.