Truthiness, None, and the mutable default
Four behaviours in this lesson. Each one runs without error while doing something other than what a TypeScript reflex expects, which makes them more expensive than syntax you get wrong loudly.
Empty containers are falsy
bool([]) # False
bool({}) # False
bool("") # False
bool(set()) # False
bool(0) # False
bool(None) # FalseIn JavaScript, [] and {} are objects and objects are truthy, so if (results) tells you the variable holds something. In Python if results: tells you the variable holds something non-empty. Those are different questions, and Python’s answer is the more useful one about ninety percent of the time.
if not results: # empty OR None. Usually what you want.
return
if results is None: # genuinely absent, distinct from empty
results = fetch()The trap is a function whose parameter is optional and whose empty case is meaningful. if not items: treats “the caller passed nothing” and “the caller passed an empty list on purpose” as the same event. Sometimes correct, sometimes a silent bug, and you have to decide which per function rather than by reflex.
None is the only absence
There is no undefined. A name is either bound or it is not, and reading an unbound name raises NameError or UnboundLocalError rather than evaluating to a second kind of nothing.
That collapses a distinction you are used to carrying, and it removes a whole family of TypeScript defensive code. It also removes the tools built around it: there is no optional chaining and no nullish coalescing.
name = user.name if user is not None else None # no a?.b
timeout = configured if configured is not None else 30 # no a ?? bThe second one has a shorter idiom, timeout = configured or 30, which is exactly || and carries exactly the same bug: it also replaces 0, "" and False. It is fine for a string that cannot legitimately be empty and wrong for anything numeric.
Always test with is None, never == None. is compares identity, None is a singleton, and a class is free to define __eq__ in a way that makes x == None return something you did not expect. Every linter in the language flags == None.
is against ==
== compares value. is compares identity, meaning the same object in memory.
Coming from TypeScript there is a tempting false mapping: == is the loose one, === is the strict one, so is must be ===. It is not. Python’s == already does no type coercion, so it is already the strict comparison you wanted. is is a third thing with no TypeScript equivalent except reference equality.
a = 256
b = 256
a is b # True, in CPython. Small ints are cached.
a = 257
b = 257
a is b # False. Outside the cache.Nothing about that is guaranteed by the language and none of it is something to rely on. It matters only because it means is on integers and strings appears to work in a REPL and on small test data, then fails on a real value. Use is for None, True and False. Nothing else.
The mutable default argument
This one is famous, and the fame is deserved.
def add_tag(tag, tags=[]):
tags.append(tag)
return tags
add_tag("a") # ['a']
add_tag("b") # ['a', 'b'] <- the same list, still there
add_tag("c") # ['a', 'b', 'c']The default expression is evaluated once, when the def statement runs, and the resulting object is stored on the function. Every call that omits the argument gets that same object. If the object is mutable, mutations accumulate for the lifetime of the process.
TypeScript evaluates a default parameter on every call that omits it, so this is a genuine language difference and not a gotcha you can reason your way to from first principles.
def add_tag(tag, tags=None):
if tags is None:
tags = []
tags.append(tag)
return tagsYou will see that three-line shape constantly. Now you know it is not defensive noise: it is the only correct way to write a mutable default.
Assignment never copies
a = [1, 2, 3]
b = a
b.append(4)
a # [1, 2, 3, 4]Identical to JavaScript, and worth saying out loud anyway because Python’s lack of const removes the visual cue you are used to. There is no const, no readonly, and no frozen-by-default anything. If you want a copy, ask for one: b = a.copy() or b = a[:] for a shallow one, copy.deepcopy(a) for the recursive one.
The immutable types are str, int, float, bool, tuple, frozenset and bytes. Everything else is mutable and shared.
Where this goes
That is the whole set of Python behaviours that will silently do the wrong thing when you write TypeScript by reflex. The next lesson is objects: self, __init__, the dunder protocols, and why nothing is private.
Try it yourself
An empty list in a condition
You write if results: where results is an empty list. Does the block run?
Show answer
Correct answer: C — No, because an empty list is falsy, and so are an empty dict, an empty string, an empty set and zero
Empty containers are falsy in Python and truthy in JavaScript, and this is the difference most likely to produce a wrong answer rather than a crash. The every-object-is-truthy option is the JavaScript rule stated correctly, which is what makes it the strongest distractor: it is not nonsense, it is exactly right about a different language. if results: is the idiomatic emptiness check and if results is not None: is what you write when you actually mean not-None.
The default that persists
A function is declared def add(item, bucket=[]): and its body appends to bucket and returns it. You call add("a") and then add("b"). What does the second call return?
Show answer
Correct answer: D — ["a", "b"], because the default list is created once at definition time and shared by every call
The default expression is evaluated ONCE, when the def statement runs, and the resulting object is reused by every call that omits the argument. So one list is shared across the whole program lifetime and accumulates. The fresh-evaluation option is what everyone assumes and is what TypeScript actually does. The previous-return-value option reaches the right answer through a completely invented mechanism, which is why it is worth including: recognising the correct output is not the same as understanding it. The fix is bucket=None plus if bucket is None: bucket = [] in the body.
is against ==
When is is the correct operator to reach for rather than ==?
Show answer
Correct answer: A — For None, True and False, which are singletons, and essentially nowhere else
is tests object identity and == tests value. Python's == already does no type coercion, so it is not the loose comparison === was invented to replace, which makes the strict-replacement-for-=== framing a false analogy that leads directly to the interning claim. Small integers and short strings genuinely ARE interned in CPython, so is appears to work on them and then fails on a value that fell outside the cache. That is the exact shape of an intermittent bug: correct on your test data, wrong in production. Reserve is for the three singletons.
One absence, not two
The mental model to carry, because you are used to a language with two kinds of nothing.
Python has None where TypeScript has both null and undefined. What follows from having only one, and what is the correct way to test for it?
Reveal answer
There is no separate undeclared-versus-declared-as-empty distinction: a name is either bound or it is not, and reading an unbound one is an error rather than undefined. So None always means a value that is deliberately absent, never one that was never set. Test with x is None and x is not None, using identity rather than equality, because None is a singleton and because a class is free to define __eq__ such that x == None returns something surprising. There is also no optional chaining operator: a?.b has no Python spelling and you write an explicit if a is not None or use getattr.
Audit an emptiness check
Find a function in any Python file you have access to that takes an optional collection argument. Read its guard clause.
You can say whether the guard means empty or means absent, whether those two cases are meant to behave differently, and if the default is a mutable literal you have spotted it.