Python, step by step

For people who already write TypeScript. Read straight down — every step is one idea and a few lines of code. Run each block, then change something small and run it again. That second part is where it sticks.

Python runs in your browser, so nothing is sent anywhere and there is no server to wait for. 84 steps.

1 · First steps

01Printing

Start here. print is console.log. Press Run.

Going further

print(a, b, sep='') changes the separator, print(x, end='') drops the newline. print(*items) spreads a list into arguments. print(x, file=sys.stderr) writes to stderr. When a value prints unhelpfully, try print(repr(x)) — that is the debug form, and it is what you see in a list or a traceback.

TypeScript you already know
console.log("hello");
Python — edit me
⌘/Ctrl + Enter

Try this — Change the text and run it again.

02Comments

# instead of //. There is no /* ... */ block comment.

Going further

# type: ignore and # noqa: E501 on the end of a line silence a type checker or ruff for that line. # ruff: noqa at the top does the whole file. You will need these occasionally; both take specific codes so you can silence one rule rather than all.

TypeScript you already know
// ignored
Python — edit me
⌘/Ctrl + Enter

Try this — Put a # in front of the print and run it. Nothing happens.

03Quotes

Single and double quotes are identical. Pick one and be consistent — most projects use double.

Going further

Use r"..." for regular expressions and Windows paths so backslashes stay literal. b"..." makes bytes rather than text — you will meet those reading files in binary mode or handling HTTP bodies. Adjacent literals concatenate: ("long " "message") is one string, which is how long text gets wrapped across lines.

Python — edit me
⌘/Ctrl + Enter

04Variables

No let, no const, no var. Assignment declares the variable.

Going further

Assign several at once with a, b = 1, 2, and swap with a, b = b, a. _ is the conventional name for a value you are deliberately ignoring, as in for _ in range(3). Underscores are legal in numbers: 1_000_000 reads better and means the same thing.

TypeScript you already know
const name = "dune";
let count = 3;
Python — edit me
⌘/Ctrl + Enter

Try this — print takes any number of arguments and puts spaces between them. Add a third.

05f-strings

Template literals, with an f in front instead of backticks.

Going further

The format spec after : is worth learning: {x:.2f} two decimals, {x:,} thousands separators, {x:>8} right-align in 8 columns, {x:<8} left, {x:^8} centre, {x:08.2f} zero-padded. {x!r} gives the repr instead of the str. For debugging, f"{x=}" prints both the expression and its value.

TypeScript you already know
console.log(`model ${name} is ready`);
Python — edit me
⌘/Ctrl + Enter

Try this — Put {1 + 1} inside the string — any expression works in there.

06Forgetting the f

Without the f the braces are just characters. No error, no warning — a genuinely common slip.

Going further

Doubling the braces escapes them: f"{{literal}}" prints {literal}. That is how you build JSON or CSS in an f-string. If you want a template filled in later rather than now, use "{name}".format(name=x) or string.Template — those take the values at call time, where an f-string takes them immediately.

Python — edit me
⌘/Ctrl + Enter

Try this — Add the missing f to the second line.

07Numbers

One surprise: / always gives a float, even for two whole numbers. // is the whole-number one.

Going further

round(x, 2) rounds to a number, f"{x:.2f}" formats to a string — different jobs. int("12") and float("1.5") parse, raising ValueError on rubbish rather than giving you NaN. divmod(a, b) gives quotient and remainder together. For money, use decimal.Decimal rather than float.

TypeScript you already know
5 / 2;              // 2.5
Math.floor(5 / 2);  // 2
Python — edit me
⌘/Ctrl + Enter

Try this — Try 0.1 + 0.2 — floats behave exactly as badly as they do in JavaScript.

08String methods

Same ideas, snake_case names. len(s) is a function, not a property.

Going further

The ones you will use constantly: .strip() / .lstrip() / .rstrip(), .split(",") and .rsplit, "-".join(parts), .startswith() / .endswith() (both accept a tuple of options), .removeprefix() / .removesuffix(), and .splitlines(). .casefold() beats .lower() for case-insensitive comparison.

TypeScript you already know
s.length;
s.toUpperCase();
s.includes('nan');
Python — edit me
⌘/Ctrl + Enter

Try this — in works on lists too. Try print(2 in [1, 2, 3]).

09replace() replaces every match

The opposite of JavaScript, where .replace() does the first only. Worth remembering.

Going further

For anything beyond a literal swap, re.sub(pattern, replacement, s) does regex replacement, also replacing all by default — pass count=1 to limit it. .translate() is the fast path for single-character mappings.

TypeScript you already know
"banana".replace("a", "X");     // "bXnana"
Python — edit me
⌘/Ctrl + Enter

Try this — The 1 is a count. Change it to 2.

10Multi-line strings

Three quotes. Also how documentation strings are written, which you will see everywhere.

Going further

Indenting a triple-quoted string inside a function puts that whitespace in the value. textwrap.dedent(text) strips the common leading indent, and a \ at the end of the first line drops the leading newline. Both are standard when embedding a prompt or a SQL query in a function.

Python — edit me
⌘/Ctrl + Enter

2 · Making decisions

11if — indentation is the block

A colon opens the block; four spaces make it. No braces, no semicolons.

Going further

There is no one-line if (x) doThing(); with braces, but if x: do_thing() on a single line is legal for short bodies. pass is the do-nothing statement when you need a body you have not written yet — Python has no empty block.

TypeScript you already know
if (n > 3) {
  console.log("big");
}
Python — edit me
⌘/Ctrl + Enter

Try this — Change 5 to 1. Then indent the last line by four spaces and see what changes.

12elif

elif, not else if. That is the whole difference.

Going further

For dispatch on a value, a dict of functions usually beats a long chain: handlers.get(kind, default_handler)(). match handles shape as well as value — you can destructure with case {"type": "a", "id": id}: or case [first, *rest]:, which is where it earns its place over elif.

Python — edit me
⌘/Ctrl + Enter

Try this — Set n to -2.

13and, or, not

Words, not symbols. True and False are capitalised.

Going further

and and or return an operand rather than a boolean, so name or 'anon' is the common default idiom — but it also replaces 0 and "", so use x if x is not None else default when those are legitimate values. not binds tighter than and/or; when in doubt, bracket it.

TypeScript you already know
a && b
a || b
!a
Python — edit me
⌘/Ctrl + Enter

14The ternary is inside out

Value first, condition in the middle. It reads like an English sentence once you get used to it.

Going further

Because it is an expression it works as a default argument, inside an f-string, or as the output half of a comprehension: [x if x > 0 else 0 for x in xs]. Nesting more than one is where readability dies — reach for a small function instead.

TypeScript you already know
const label = n > 3 ? "big" : "small";
Python — edit me
⌘/Ctrl + Enter

Try this — Change 5 to 1.

15⚠️ An empty list is False

This one will catch you. In TypeScript [] is truthy; in Python it is falsy. Same for {} and "".

Going further

Write if items: rather than if len(items) > 0: — the first is idiomatic and the second reads as noise. The exception is when None and empty mean different things: if items is None: distinguishes 'not supplied' from 'supplied but empty', which matters constantly in API code.

TypeScript you already know
if ([]) { console.log("TS runs this"); }
Python — edit me
⌘/Ctrl + Enter

Try this — Put something in the list and run it again.

16Chained comparisons

0 < x < 10 means what it looks like it means. TypeScript cannot do this.

Going further

Works with any comparison operators and mixes them freely: 0 <= i < len(xs) is the standard bounds check. It also chains is and in, though that reads badly. The one trap is that a == b == c tests all three are equal, not (a == b) == c.

TypeScript you already know
0 < x && x < 10      // the only option
Python — edit me
⌘/Ctrl + Enter

17in

One keyword for 'is it in there', across strings, lists and dicts.

Going further

not in is a single operator and reads better than not (x in y). Cost matters: in on a list scans it, on a set or dict it is a hash lookup. If you are membership-testing inside a loop, build a set() first. For dicts, in tests keys — use value in d.values() if you meant the other thing.

TypeScript you already know
xs.includes(2);
"a" in obj;
Python — edit me
⌘/Ctrl + Enter

Try this — The last line is False because 1 is a value, not a key.

3 · Lists

18Making a list

Square brackets, same as an array. len() again rather than .length.

Going further

list(x) converts anything iterable — a string, a set, a generator, dict.keys(). Other things you will want: xs.count(v), xs.index(v), xs.remove(v) (by value, raises if absent), xs.clear(), and reversed(xs). a + b concatenates and a * 3 repeats — watch the trap in part 9.

TypeScript you already know
const xs = [1, 2, 3];
xs.length;
Python — edit me
⌘/Ctrl + Enter

19Negative indexing

-1 is the last item. There is no TypeScript equivalent and you will miss it when you go back.

Going further

Guard with if xs: before xs[-1], because an empty list raises IndexError. When you want a safe fetch, slicing does not raise: xs[-1:] gives [] rather than blowing up, and next(iter(xs), None) gets the first item or None.

TypeScript you already know
xs[xs.length - 1];
Python — edit me
⌘/Ctrl + Enter

20Slicing

xs[start:end] — end is excluded, like .slice(). Leaving a side blank means 'all the way'.

Going further

Slices assign as well as read: xs[1:3] = [9] replaces that span with a different number of items, and del xs[1:3] removes it. xs[:] is the shallow-copy idiom and xs[::2] takes every other. All of it works on strings and tuples too, returning the same type.

TypeScript you already know
xs.slice(1, 3);
Python — edit me
⌘/Ctrl + Enter

Try this — Note the last line: an out-of-range slice gives [] rather than an error.

21Adding items

append for one, extend for many.

Going further

xs.insert(0, v) puts one at the front, but it is O(n) — if you are queueing, collections.deque has appendleft and popleft in constant time. xs.pop() takes from the end, xs.pop(0) from the front, and both return the item.

TypeScript you already know
xs.push(4);
xs.push(...ys);
Python — edit me
⌘/Ctrl + Enter

Try this — Swap the last append for extend and compare.

22Looping

for x in xs is for (const x of xs). That is the only loop you will normally write.

Going further

Never mutate a list while looping over it — iterate list(xs) or build a new list instead. To loop over several things at once use zip; to loop with an index use enumerate; to loop over a dict's pairs use .items(). itertools has the rest: chain, islice, groupby, product.

TypeScript you already know
for (const x of xs) console.log(x);
Python — edit me
⌘/Ctrl + Enter

23range

For counting. range(3) gives 0, 1, 2 — the end is excluded, as always.

Going further

range(len(xs)) is almost always the wrong call — enumerate(xs) says what you mean. range counts backwards with a negative step: range(10, 0, -1). It supports in, so x in range(0, 100, 5) is a cheap membership test rather than a scan.

TypeScript you already know
for (let i = 0; i < 3; i++) { }
Python — edit me
⌘/Ctrl + Enter

Try this — range(2, 5) starts at 2, and range(0, 10, 2) steps by 2.

24enumerate — index and value

When you need the position too.

Going further

enumerate(xs, start=1) numbers from one for human-facing output. It works on anything iterable, including a file object, which makes for lineno, line in enumerate(f, 1) the standard way to report errors with a line number.

TypeScript you already know
for (const [i, x] of xs.entries()) { }
Python — edit me
⌘/Ctrl + Enter

25zip — two lists at once

No TypeScript equivalent. Very handy — and it stops at the shortest.

Going further

zip(a, b, strict=True) raises if the lengths differ — use it whenever they are supposed to match, because the default silently truncates. zip(*rows) transposes. itertools.zip_longest pads the short one instead of stopping.

Python — edit me
⌘/Ctrl + Enter

Try this — Add strict=True as a third argument to zip and see it complain instead.

26Comprehensions — the .map()

The shape that makes Python look alien. Output expression first, then where it comes from.

Going further

map(f, xs) and filter(p, xs) exist but are considered less readable and return lazy iterators, so you would need list(...) anyway. The one place map still wins is with an existing function and no lambda: map(str.upper, names).

TypeScript you already know
xs.map((x) => x * 2);
Python — edit me
⌘/Ctrl + Enter

Try this — Change x * 2 to x + 10.

27Comprehensions — the .filter()

Add if on the end. Read it as: give me x, for each x, where the condition holds.

Going further

Two ifs in a row means AND: [x for x in xs if x > 0 if x < 10]. The if BEFORE the for is a different thing — it transforms rather than filters, and needs the full x if cond else other form. Swap the brackets for {} to build a set or dict, or () for a lazy generator.

TypeScript you already know
xs.filter((x) => x > 1);
Python — edit me
⌘/Ctrl + Enter

Try this — Now do both at once: [x * 2 for x in xs if x > 1].

28sorted

Takes a key function, not a comparator. And it sorts numbers properly — JavaScript's .sort() compares them as text.

Going further

key= takes any function: key=len, key=str.lower, key=lambda r: r["name"]. For several fields, return a tuple: key=lambda r: (r["team"], -r["score"]) — and note the minus for descending on one field only. operator.itemgetter("name") is the faster form of the common lambda.

TypeScript you already know
[...xs].sort((a, b) => a - b);
Python — edit me
⌘/Ctrl + Enter

Try this — Sort a list of dicts: sorted(rows, key=lambda r: r["name"]).

29⚠️ .sort() returns None

sorted() gives you a new list. .sort() mutates and returns nothing. Mixing them up is a classic.

Going further

The same rule holds across the library: mutating methods return None. So xs = xs.sort() silently gives you None, xs = xs.append(1) too. If you see a mysterious NoneType error one line later, this is usually why. Use sorted(), reversed() or xs[:] when you want a value back.

TypeScript you already know
const sorted = [...xs].sort();   // returns the array
Python — edit me
⌘/Ctrl + Enter

30any and all

.some() and .every().

Going further

Pass a generator expression, not a list comprehension — any(x > 2 for x in xs) stops at the first match, the list version evaluates everything first. To find out WHICH one matched rather than whether any did, use the next(...) idiom from the next step.

TypeScript you already know
xs.some((x) => x > 2);
xs.every((x) => x > 0);
Python — edit me
⌘/Ctrl + Enter

31find

There is no .find(). This is the idiom — the second argument is what you get if nothing matches.

Going further

Without a default, next() raises StopIteration when nothing matches, so always pass the second argument. To get all matches instead of the first, use a comprehension. xs.index(v) finds a position but only by equality and raises ValueError if absent.

TypeScript you already know
xs.find((x) => x > 1);   // undefined if none
Python — edit me
⌘/Ctrl + Enter

32sum, min, max

Built in, so you rarely need reduce.

Going further

max(rows, key=lambda r: r["size"]) gets the biggest by a field rather than the biggest value. max(xs, default=0) avoids the empty-sequence error. sum(xs, start=0), sorted, min and max all accept the same key. For counting, collections.Counter(xs).most_common(3).

TypeScript you already know
xs.reduce((a, x) => a + x, 0);
Python — edit me
⌘/Ctrl + Enter

4 · Loops in depth

33while, break, continue

All three behave exactly as you expect. No do...while.

Going further

while True: with an inner break is the normal way to write a loop that exits from the middle — there is no do...while. There are no labelled breaks either, so to leave nested loops either set a flag, or put the loops in a function and return. The second is usually cleaner.

TypeScript you already know
while (n < 5) {}
Python — edit me
⌘/Ctrl + Enter

34for ... else

A loop can have an else. It runs only if the loop finished *without* hitting break.

Going further

The same else works on while. It is only worth using for search loops; most people find a helper function that returns early clearer. Know it mainly so it does not confuse you in someone else's code — else here means 'no break', not 'if empty'.

Python — edit me
⌘/Ctrl + Enter

Try this — Change 99 to 2 and run it again — the else is skipped.

35Nested loops and nested comprehensions

The for clauses in a comprehension read left to right, outermost first — the same order you would write the loops.

Going further

Once you need two for clauses in a comprehension, consider whether a real loop reads better. itertools.product(a, b) replaces a nested loop over two collections, and itertools.chain.from_iterable(rows) flattens one level without a comprehension at all.

Python — edit me
⌘/Ctrl + Enter

36⚠️ Loops leak, comprehensions do not

A for loop variable outlives the loop. A comprehension's does not. Two different rules in one language.

Going further

Practical upshot: do not rely on a loop variable after the loop unless you meant to, and do not expect a comprehension's variable to exist at all. If you need the last value, assign it explicitly inside the loop.

Python — edit me
⌘/Ctrl + Enter

Try this — The loop variable survived as 2; the comprehension left x untouched.

37Round brackets make it lazy

Square brackets build a list. Round brackets build a generator that produces items one at a time.

Going further

Write your own with yield inside a def. Use them when the source is large or infinite, or when you are piping through several stages. itertools.islice(gen, 10) takes the first ten without consuming the rest. The catch — single use — is in part 9.

Python — edit me
⌘/Ctrl + Enter

5 · Dicts

38A dict is an object

Keys are quoted strings. There is no dot access — always square brackets.

Going further

Keys can be any hashable value: strings, numbers, tuples, enums — but not lists or dicts. dict(name="x", size=8) is an alternative constructor when the keys are valid identifiers. dict.fromkeys(keys, 0) builds one with a shared default, but be careful if that default is mutable.

TypeScript you already know
const d = { name: "dune" };
d.name;
Python — edit me
⌘/Ctrl + Enter

39⚠️ A missing key raises

In TypeScript you get undefined. In Python you get an exception — unless you use .get().

Going further

Three tools beyond .get(): d.setdefault(k, []) inserts and returns in one step; collections.defaultdict(list) makes missing keys spring into existence, which is the usual way to group things; collections.Counter does counting. Use .get() for reads and defaultdict when you are accumulating.

TypeScript you already know
d.missing;   // undefined
Python — edit me
⌘/Ctrl + Enter

Try this — This one fails on purpose — read the error, then delete the last line.

40Adding and removing

Assign to add or overwrite; del to remove.

Going further

d.pop(k, default) removes and returns without risk of raising. d.update(other) merges in place. d.clear() empties it. If you need to delete while iterating, loop over list(d.keys()) — mutating during iteration raises.

TypeScript you already know
d.size = 8;
delete d.size;
Python — edit me
⌘/Ctrl + Enter

41Looping a dict

Looping it directly gives you the keys. .items() gives both, unpacked into two variables.

Going further

.items() is the one you want most of the time. Sort as you go with sorted(d.items(), key=lambda kv: kv[1]) to order by value. The views are live, so wrap in list() if you plan to modify the dict inside the loop.

TypeScript you already know
for (const [k, v] of Object.entries(d)) { }
Python — edit me
⌘/Ctrl + Enter

Try this — Try for key in d: instead and print just the key.

42Dicts keep order. Sets do not.

Insertion order is guaranteed for dicts since 3.7. A set has no order at all, and its print order can change between runs.

Going further

Because insertion order holds, list(d)[0] is the first key inserted and d.popitem() removes the last. Sets have no order, so always sorted(s) before printing, comparing output or writing a test. Two sets still compare equal regardless of order.

Python — edit me
⌘/Ctrl + Enter

43Dict comprehension

Same shape as a list comprehension, with key: value at the front.

Going further

Invert a dict with {v: k for k, v in d.items()}. Filter one with {k: v for k, v in d.items() if v}. The empty set has no literal — {} is a dict, so use set().

TypeScript you already know
Object.fromEntries(xs.map((x) => [x, x * 2]));
Python — edit me
⌘/Ctrl + Enter

44Merging

** is the spread operator for dicts. | does the same thing and reads better.

Going further

Right-hand side wins, so defaults | overrides is the config idiom. |= merges in place. The same ** expands a dict into keyword arguments at a call site: f(**options) — handy for passing settings through.

TypeScript you already know
const merged = { ...a, ...b };
Python — edit me
⌘/Ctrl + Enter

6 · Functions

45def

def, a colon, an indented body. The -> int is only a note for readers — nothing checks it.

Going further

A function with no return gives back None. Functions are values — pass them, store them in a dict, return them. Nest one inside another when it is only used there. Add a docstring as the first line and help(f) will show it.

TypeScript you already know
function add(a: number, b: number): number {
  return a + b;
}
Python — edit me
⌘/Ctrl + Enter

46Default parameters

Identical to TypeScript — with one sharp edge two steps below.

Going further

Defaults are evaluated once at definition, which is fine for numbers and strings and dangerous for lists and dicts — see the trap two steps down. A default can reference earlier parameters only at call time inside the body, not in the signature.

TypeScript you already know
function greet(name: string, greeting = "hi") { }
Python — edit me
⌘/Ctrl + Enter

47Named arguments

Any parameter can be passed by name. This replaces the options-object pattern you use in TypeScript.

Going further

Named arguments mean you can reorder freely and skip middle ones with defaults. When a call site has more than two or three arguments, naming them is the difference between readable and not. You will see this everywhere in FastAPI and Pydantic code.

TypeScript you already know
greet({ name: "nish", greeting: "hello" });
Python — edit me
⌘/Ctrl + Enter

48*args and **kwargs

* collects extra positional arguments into a tuple; ** collects extra named ones into a dict.

Going further

Useful for wrappers that pass everything through: def wrapper(*args, **kwargs): return inner(*args, **kwargs). That is how decorators are written. You can combine them with normal parameters — def f(first, *rest, **options) — as long as the order is positional, *, then **.

TypeScript you already know
function show(...args: unknown[]) { }
Python — edit me
⌘/Ctrl + Enter

49Forcing names with *

Everything after the * must be passed by name. Nothing like it in TypeScript, and it makes call sites much clearer.

Going further

Use it whenever a function takes flags or options, so nobody can write run(prompt, True, False). You can also force the opposite with /: parameters before it are positional-only. Both let you rename or reorder later without breaking callers.

Python — edit me
⌘/Ctrl + Enter

Try this — The last line fails on purpose. Read the message, then add temperature=.

50⚠️ The mutable default trap

The default [] is created once, when the function is defined — not on each call. The classic Python bug.

Going further

The fix is always def f(items=None): then items = [] if items is None else items. It applies to dicts, sets and any object you mutate — and to class attributes, which is the same bug in a different place (part 8). Ruff's B006 rule catches it if you enable the bugbear rules.

TypeScript you already know
function add(x, into = []) { into.push(x); return into; }
add(1);  // [1]
add(2);  // [2]
Python — edit me
⌘/Ctrl + Enter

Try this — Fix it: use into=None and add into = [] if into is None else into as the first line.

51Closures and nonlocal

Reading an outer variable just works. *Assigning* to one needs nonlocal, or you silently create a new local.

Going further

nonlocal for an enclosing function's variable, global for a module-level one. Needing either is often a sign that a small class or a returned value would be clearer. The one place closures genuinely shine is decorators and callbacks.

TypeScript you already know
let n = 0;
const bump = () => ++n;
Python — edit me
⌘/Ctrl + Enter

Try this — Delete the nonlocal line and read the error carefully.

7 · None and types

52None

One empty value, not two. There is no undefined. Compare it with is, never ==.

Going further

Always is None / is not None, never == None. Use Optional[str] or the newer str | None in annotations to say a value may be missing. A common shape is def f(x: str | None = None) — optional in both the type and the default.

TypeScript you already know
let x: string | null = null;
if (x !== null) { }
Python — edit me
⌘/Ctrl + Enter

53⚠️ There is no ?.

The feature you will miss most. These are the replacements.

Going further

For nested dicts, chain .get() with a {} default: d.get("a", {}).get("b"). For objects, getattr(obj, "name", None) is the safe attribute read. If the whole chain is speculative, a try/except AttributeError block is idiomatic here in a way it would not be in TypeScript.

TypeScript you already know
obj.name?.length ?? -1;
Python — edit me
⌘/Ctrl + Enter

Try this — Those last two lines differ — that is the or trap.

54⚠️ Type hints do nothing

The syntax looks like TypeScript. The guarantee is absent — nothing checks it, at any point.

Going further

Annotations become real in two places worth knowing: Pydantic and FastAPI read them to validate and to generate OpenAPI, and a checker like mypy or pyright will flag mistakes before you run. Adding mypy or pyright to a project is the closest you get to the TypeScript experience.

TypeScript you already know
function double(x: number) { return x * 2; }
double("3");   // compile error, will not build
Python — edit me
⌘/Ctrl + Enter

Try this — No error at all. This is why anything crossing a boundary gets validated with Pydantic — see part 10.

55is vs ==

== compares contents. is asks whether they are the same object. Use is only for None, True and False.

Going further

is for None, True, False and enum members. == for everything else. Never use is on numbers or strings even when it appears to work — small values are cached, so it succeeds by accident and fails on the one input that matters.

TypeScript you already know
[1] === [1];   // false
Python — edit me
⌘/Ctrl + Enter

8 · Classes

56A class

__init__ is the initialiser. self is this, but you must declare it as the first parameter.

Going further

Add __repr__ early — it is what you see in logs, lists and the debugger, and the default is useless. __eq__ gives you value equality. If the class is only holding data, @dataclass writes both for you and is usually the better starting point.

TypeScript you already know
class Book {
  constructor(public name: string) {}
}
new Book('dune');
Python — edit me
⌘/Ctrl + Enter

Try this — Note there is no new. Calling the class builds one.

57Methods

Every method takes self first. You never pass it at the call site.

Going further

@staticmethod takes no self and is just a function living on the class. @classmethod takes cls instead and is the standard way to write alternative constructors: Model.from_json(text). Both are worth knowing because you will read them constantly.

TypeScript you already know
describe() { return `model ${this.name}`; }
Python — edit me
⌘/Ctrl + Enter

Try this — Delete self from the describe line and read the error.

58@property

A method that is read like a field — TypeScript's get. Note the missing parentheses at the call site.

Going further

Add a setter with @url.setter under a matching method name if it should be writable. Use a property for something cheap and derived; if it does real work, make it a normal method so the call site shows the cost. functools.cached_property computes once and remembers.

TypeScript you already know
get url() { return `http://${this.host}`; }
Python — edit me
⌘/Ctrl + Enter

Try this — Add the parentheses — .url() — and see what breaks.

59Inheritance and super()

The base class goes in brackets after the name. super() needs no arguments.

Going further

Call super().__init__(...) first in a subclass __init__ or the base's setup never runs. Prefer composition for reuse and inheritance for genuine is-a relationships. For an interface, typing.Protocol gives you structural typing without a base class at all.

TypeScript you already know
class Sub extends Base {
  describe() { return super.describe(); }
}
Python — edit me
⌘/Ctrl + Enter

60⚠️ Class attributes are shared

A value assigned in the class body belongs to the class, not to each instance. Mutating it affects every instance.

Going further

Anything mutable belongs in __init__ as self.x = []. A class attribute is fine and useful for constants and defaults that never change: MAX_RETRIES = 3. @dataclass refuses a mutable default outright and makes you write field(default_factory=list) — worth copying that discipline.

TypeScript you already know
class Book { tags: string[] = []; }   // per-instance in TS
Python — edit me
⌘/Ctrl + Enter

Try this — Move tags = [] into __init__ as self.tags = [] and run it again.

61@dataclass

For plain data. You get the constructor, a readable print and equality for free.

Going further

field(default_factory=list) for mutable defaults. frozen=True makes it immutable and hashable, so it can go in a set or be a dict key. dataclasses.asdict(obj) converts to a plain dict, and replace(obj, x=2) copies with a change. Reach for Pydantic instead when the data comes from outside and needs validating.

TypeScript you already know
interface Point { x: number; y: number }
Python — edit me
⌘/Ctrl + Enter

9 · Traps for a TypeScript developer

62⚠️ [[]] * 3 gives you one list, three times

Multiplying a list repeats the *references*, not the contents. All three entries are the same object.

Going further

Use [[] for _ in range(n)] whenever the element is mutable. [0] * n and [None] * n are safe and are the normal way to preallocate. The same trap applies to dict.fromkeys(keys, []) — every key shares one list.

TypeScript you already know
Array(3).fill([]);   // exactly the same trap in TS
Python — edit me
⌘/Ctrl + Enter

63Assignment never copies

b = a binds a second name to the same list. Familiar from TypeScript, but the copying idioms are different.

Going further

list(a) or a[:] for a shallow copy, copy.deepcopy(a) when the contents are nested and you need them independent too. Deep copies are slow, so prefer building new data over copying and mutating. Passing a list to a function that mutates it changes yours — there is no readonly.

TypeScript you already know
const b = a;          // same array
const c = [...a];     // shallow copy
Python — edit me
⌘/Ctrl + Enter

64⚠️ Closures capture the variable, not its value

Every lambda made in this loop sees the *final* value of i. The classic var bug, except Python has no let to fix it.

Going further

The i=i default-argument trick is the standard fix. functools.partial(f, i) does the same thing more explicitly. It matters most when building callbacks or handlers in a loop — the exact situation where the bug is hardest to spot.

TypeScript you already know
for (var i = 0; i < 3; i++) fns.push(() => i);   // all 3
// `let` fixes it in TS. Python has no equivalent.
Python — edit me
⌘/Ctrl + Enter

65⚠️ A generator can only be consumed once

Iterate it twice and the second pass is empty. No error — just nothing.

Going further

If you need it twice, items = list(gen) first. Watch for it when passing a generator to two functions, or calling sum() then looping. itertools.tee splits one into several, but it buffers, so list() is usually simpler and clearer.

Python — edit me
⌘/Ctrl + Enter

66⚠️ The `as e` variable is deleted after the block

Python explicitly unbinds it when the except block ends. Use it after, and you get a NameError.

Going further

Copy what you need inside the block: message = str(e). You get str(e) for the message, e.args for the raw arguments, and type(e).__name__ for the class name. traceback.format_exc() gives the whole formatted traceback as a string, which is what you usually want in a log.

TypeScript you already know
try {} catch (e) { }
// `e` is block-scoped in TS, but not deleted
Python — edit me
⌘/Ctrl + Enter

67Tuples are not just immutable lists

A trailing comma makes a tuple. One-element tuples need it, and a stray one turns a value into a tuple by accident.

Going further

Tuples are hashable, so use one as a dict key or set member when you need a composite. They are also what a function returns for multiple values. namedtuple or a frozen dataclass is better once the fields have meaning — point.x beats point[0].

TypeScript you already know
const t: [string, number] = ['a', 1];
Python — edit me
⌘/Ctrl + Enter

68Modern conveniences: := and f"{x=}"

The walrus assigns inside an expression. = inside an f-string prints the expression *and* its value.

Going further

Most useful in a while reading loop — while (chunk := f.read(8192)): — and in comprehensions to avoid computing twice: [y for x in xs if (y := f(x)) is not None]. Keep it to those; a walrus in ordinary code usually just hides an assignment.

Python — edit me
⌘/Ctrl + Enter

10 · The real thing

69Pydantic — a type that is real

The standard way to describe data arriving from outside. Unlike a TypeScript interface it exists at runtime and checks.

Going further

Field(default=..., description=..., ge=0, max_length=100) adds constraints and docs. model_validate(some_dict) builds from a dict, model_validate_json(text) from JSON. Nested models just work — declare a field as another model and it validates all the way down. This is the whole reason annotations are worth writing.

TypeScript you already know
interface Package {
  name: string;
  sizeGb: number;
}
Python — edit me
⌘/Ctrl + Enter

70Pydantic rejecting bad data

Compare with 'Type hints do nothing' in part 7, where a wrong type sailed straight through.

Going further

e.errors() gives a structured list — field location, message, input value — which is what you turn into an API response. FastAPI does exactly that automatically and returns a 422. Use @field_validator for a rule the type system cannot express, and model_config = ConfigDict(extra="forbid") to reject unknown keys.

Python — edit me
⌘/Ctrl + Enter

Try this — Change "not a number" to "5.2" — a numeric string is accepted and converted.

71try / except

except is catch, and you name the type you want. finally works the same.

Going further

Catch anything with except Exception as e: — that covers every normal error. A bare except: also swallows Ctrl-C and SystemExit, so avoid it. Several types: except (ValueError, KeyError) as e:. Your own: class ConfigError(Exception): pass — subclass Exception, and that is the whole definition. Re-raise the current one with a bare raise. The ones you will meet most: ValueError, TypeError, KeyError, IndexError, AttributeError, FileNotFoundError, ZeroDivisionError, TimeoutError. Catch the narrowest that fits.

TypeScript you already know
try { risky(); } catch (e) { } finally { }
Python — edit me
⌘/Ctrl + Enter

72try has an else too

else runs only when nothing was raised. It keeps the try block down to the line that can actually fail.

Going further

Order is tryexceptelsefinally. Keep the try block down to the one line that can fail and put the follow-up in else, so you never catch an exception from the wrong statement. contextlib.suppress(FileNotFoundError) is the tidy form of try/except/pass.

Python — edit me
⌘/Ctrl + Enter

73Raising your own

A custom error is a class. from e keeps the original cause, like { cause: e }.

Going further

Subclass Exception, not BaseException. Give your errors a common base — class AppError(Exception) with class ConfigError(AppError) — so callers can catch broadly or narrowly. raise X from e keeps the cause; raise X from None deliberately hides it when the original would only confuse.

TypeScript you already know
class Unreachable extends Error {}
throw new Unreachable('x', { cause: e });
Python — edit me
⌘/Ctrl + Enter

74with

Cleanup that always happens — try/finally for resources. async with is the version you use for network clients.

Going further

with open(path) as f: is the everyday case — it closes even if you raise. Several at once: with open(a) as f, open(b) as g:. Write your own quickly with @contextlib.contextmanager on a generator that yields once. contextlib.suppress and tempfile.TemporaryDirectory are the two you will reach for soonest.

TypeScript you already know
const f = open();
try { use(f); } finally { f.close(); }
Python — edit me
⌘/Ctrl + Enter

Try this — Raise an error inside the with block — it still prints "closed".

75async / await

Almost identical to TypeScript. One difference: calling an async function does nothing until you await it. This page runs your code inside an event loop already, so await works at the top level here. In a script you would wrap it as asyncio.run(main()).

Going further

asyncio.gather(*tasks) runs them concurrently; add return_exceptions=True to collect failures instead of aborting. asyncio.wait_for(coro, timeout=5) bounds one. Use async with for clients and async for over streams. The rule that catches everyone: one blocking call inside an async function stalls the whole loop — use the async library, or asyncio.to_thread(fn).

TypeScript you already know
const p = one();   // already running
await Promise.all([one(), one()]);
Python — edit me
⌘/Ctrl + Enter

11 · Generators and streaming

76return vs yield

return hands back one value and the function is over. yield hands back a value and pauses, ready to carry on.

Going further

A function is a generator function if yield appears anywhere in its body — even in a branch that never runs. That is why adding one yield to an existing function silently changes what callers get back. You can have both: return value inside a generator sets StopIteration.value, which yield from picks up but a plain for loop ignores.

TypeScript you already know
function one() { return 1; }
function* many() { yield 1; yield 2; }
Python — edit me
⌘/Ctrl + Enter

Try this — The second return never runs. Both yields do.

77Calling it runs nothing

A function containing yield returns a generator immediately. Not one line of the body has run yet.

Going further

next(gen) pulls one value; next(gen, default) avoids StopIteration at the end. A for loop calls next for you and stops on StopIteration. Laziness is the point: you can wrap an infinite source and take only what you need with itertools.islice(gen, 10).

Python — edit me
⌘/Ctrl + Enter

Try this — Each next() runs to the following yield and stops there.

78State survives the pause

Local variables are still there when it resumes. That is what makes generators useful for running totals and parsers.

Going further

Because the frame is kept alive, a generator is a cheap alternative to a small class with instance state. Add cleanup with try/finally around the yield — the finally runs when the generator is closed or garbage-collected, which is how @contextlib.contextmanager builds a with block out of a generator.

Python — edit me
⌘/Ctrl + Enter

79return inside a generator ends it

A bare return stops iteration. It does not produce a value — it is the exit door.

Going further

yield from other_generator() delegates: it forwards every value and is how you compose generators into a pipeline. gen.close() stops one early, and gen.throw(exc) raises inside it — both rarely needed, but they explain why a generator can have a finally that still runs.

Python — edit me
⌘/Ctrl + Enter

Try this — while True is safe here because the return breaks out.

80async def + yield

Put yield in an async def and you get an async generator, consumed with async for. This is how a streamed HTTP response is read. This page runs your code inside an event loop already, so await works at the top level here. In a script you would wrap it as asyncio.run(main()).

Going further

async for needs an async generator; a normal for over one raises. Comprehensions work too: [x async for x in ticks(3)]. contextlib.aclosing(gen) guarantees cleanup if you break out early. The rule that bites: one blocking call inside the loop stalls the whole event loop — use the async client, or asyncio.to_thread.

TypeScript you already know
for await (const x of stream()) {}
Python — edit me
⌘/Ctrl + Enter

81NDJSON — one object per line

How streaming APIs send a sequence of results. Not a JSON array — a stream of complete JSON objects separated by newlines, so you can parse each as it lands.

Going further

Real streams do not split on message boundaries, so a line can arrive in two pieces — buffer until you see the delimiter rather than parsing whatever the last read gave you. httpx's response.aiter_lines() handles that for you; aiter_bytes() does not. Never json.loads a whole NDJSON body: it is not valid JSON as a document.

Python — edit me
⌘/Ctrl + Enter

Try this — Note the blank line is skipped — real streams contain them.

82SSE — data: lines, blank line ends a frame

What this app sends to the browser. A frame is one or more data: lines; a blank line terminates it.

Going further

The full format also has event:, id: and retry: lines, and a leading : marks a comment used as a keep-alive. Browsers have EventSource built in, but it is GET-only and cannot send a JSON body — which is why this app reads the stream with fetch and a reader instead. Frames can split across reads, so buffer on \n\n.

Python — edit me
⌘/Ctrl + Enter

Try this — The second frame has two data lines and becomes one payload.

83Putting it together

A generator reading NDJSON and stopping on the terminal object. This is the shape of a real streaming client, minus the network.

Going further

In the real version each step is separated: the transport yields lines, a parser turns lines into typed chunks, and the route wraps chunks in SSE frames. Keeping them apart is what makes the parser testable without a network — feed it a list of strings. Add cancellation with an AbortSignal on the client and it stops mid-stream.

Python — edit me
⌘/Ctrl + Enter

Try this — Make the model reply arrive in three pieces instead of two.

84Your turn

A blank block. Try something you are unsure about — that is what it is for.

Going further

Imports work here, so anything in the standard library is available. A few third-party packages load on demand too — import pydantic in a block and it will be fetched before the code runs.

Python — edit me
⌘/Ctrl + Enter