Chapter 11 — Classes Without TypeScript Assumptions
1. Opening problem
A TypeScript developer may write:
class User:
def __init__(self, email: str) -> None:
self._email = email
def get_email(self) -> str:
return self._email
def set_email(self, email: str) -> None:
self._email = email
This is valid Python, but it mechanically imports a Java-style accessor pattern.
In Python, a public attribute is often the simplest API:
class User:
def __init__(self, email: str) -> None:
self.email = email
If validation becomes necessary later, a property can preserve the public syntax.
Python classes should be designed around Python's object and attribute model, not translated mechanically from TypeScript.
2. Classes are executable statements
class User:
role = "member"
def greet(self) -> str:
return "Hello"
A class statement executes its body, creates a namespace, and produces a class object.
The class itself is an object:
print(type(User))
Typically:
<class 'type'>
The name User is bound to the resulting class object.
This allows classes to be:
- passed to functions;
- stored in registries;
- decorated;
- created dynamically;
- inspected;
- used as factories.
3. Instance creation
user = User()
Conceptually involves:
- calling the class object;
- allocating an instance with
__new__; - initializing it with
__init__; - returning the instance.
__init__ is not technically the constructor that creates the object. It initializes an already-created instance.
For ordinary classes, you usually implement only __init__.
4. self
class Greeter:
def greet(
self,
name: str,
) -> str:
return f"Hello, {name}"
self is not a reserved keyword, but it is the universal convention.
Call:
greeter = Greeter()
greeter.greet("Steve")
Conceptually:
Greeter.greet(
greeter,
"Steve",
)
Methods are functions that become bound through attribute access.
5. Instance attributes
class User:
def __init__(
self,
email: str,
) -> None:
self.email = email
self.active = True
Each instance receives its own attributes:
first = User(
"first@example.com"
)
second = User(
"second@example.com"
)
first.active = False
second.active remains True.
Instance attributes are often stored in instance.__dict__, unless slots or other mechanisms are used.
6. Class attributes
class User:
default_role = "member"
def __init__(
self,
email: str,
) -> None:
self.email = email
Access:
print(User.default_role)
print(user.default_role)
When accessed through an instance, Python first checks the instance and then the class hierarchy.
Assignment through the instance creates or changes an instance attribute:
user.default_role = "admin"
This shadows the class attribute for that instance.
print(user.default_role)
print(User.default_role)
Output:
admin
member
The class attribute was not changed.
7. Mutable class attributes
Dangerous:
class Team:
members: list[str] = []
Every instance shares the same list.
first = Team()
second = Team()
first.members.append("Mika")
print(second.members)
Output:
['Mika']
Use an instance attribute:
class Team:
def __init__(self) -> None:
self.members: list[str] = []
Class attributes are appropriate for:
- constants;
- shared immutable metadata;
- descriptors;
- registries intentionally shared;
- default values that are never mutated.
8. Attribute lookup
For:
instance.attribute
Python conceptually checks several places, including:
- data descriptors on the class;
- instance dictionary;
- non-data descriptors and ordinary class attributes;
- base classes;
__getattr__fallback.
The exact rules will be explored further in the descriptor chapter.
The important lesson is:
Attribute access is behaviour, not merely dictionary lookup.
Properties, methods, and descriptors can participate.
9. Public attributes and properties
Start simple:
class User:
def __init__(
self,
email: str,
) -> None:
self.email = email
Later add validation without changing caller syntax:
class User:
def __init__(
self,
email: str,
) -> None:
self.email = email
@property
def email(self) -> str:
return self._email
@email.setter
def email(
self,
value: str,
) -> None:
normalized = (
value.strip().lower()
)
if "@" not in normalized:
raise ValueError(
"Invalid email"
)
self._email = normalized
Callers still use:
user.email
user.email = "new@example.com"
This is why Python does not require preventive getters and setters.
10. Encapsulation by convention
Python has no strict private field enforcement comparable to some languages.
Conventions:
self.public
self._internal
self.__name_mangled
A single underscore means:
Internal implementation detail; external code should avoid relying on it.
Double leading underscores trigger name mangling:
class Example:
def __init__(self) -> None:
self.__value = 10
Stored under a name similar to:
_Example__value
Name mangling mainly prevents accidental collisions in subclasses. It is not security.
Do not use double underscores merely to simulate hard privacy.
11. Instance methods
class Account:
def deposit(
self,
amount: float,
) -> None:
...
Instance methods operate on an instance.
They receive self through binding.
Use them when behaviour depends on instance state.
12. Class methods
class User:
def __init__(
self,
email: str,
) -> None:
self.email = email
@classmethod
def from_raw_email(
cls,
value: str,
) -> "User":
normalized = (
value.strip().lower()
)
return cls(normalized)
cls receives the class used for the call.
user = User.from_raw_email(
" Steve@Example.com "
)
Class methods are useful for alternative constructors.
They preserve subclass construction better than hardcoding the class name.
13. Static methods
class EmailAddress:
@staticmethod
def normalize(
value: str,
) -> str:
return value.strip().lower()
Static methods receive neither self nor cls.
Ask whether the function really belongs inside the class.
A module-level function may be clearer:
def normalize_email(
value: str,
) -> str:
return value.strip().lower()
Use a static method when namespace grouping adds clear value.
Do not use it merely because another language organizes all functions inside classes.
14. Alternative constructors
from datetime import date
class Employee:
def __init__(
self,
name: str,
birth_date: date,
) -> None:
self.name = name
self.birth_date = birth_date
@classmethod
def from_iso_date(
cls,
name: str,
birth_date: str,
) -> "Employee":
return cls(
name=name,
birth_date=date.fromisoformat(
birth_date
),
)
Alternative constructors allow:
- different input formats;
- migration from legacy data;
- parsing at boundaries;
- named construction semantics.
Keep validation consistent across constructors.
15. Invariants
A class should protect conditions that must always hold.
class BankAccount:
def __init__(
self,
account_id: str,
) -> None:
if not account_id.strip():
raise ValueError(
"account_id must not be blank"
)
self._account_id = account_id
self._balance = 0
Methods preserve invariants:
def withdraw(
self,
amount: int,
) -> None:
if amount <= 0:
raise ValueError(
"amount must be positive"
)
if amount > self._balance:
raise ValueError(
"insufficient funds"
)
self._balance -= amount
Do not expose mutable internal collections if callers can violate invariants.
16. Defensive exposure
Dangerous:
class Team:
def __init__(self) -> None:
self._members: list[str] = []
@property
def members(self) -> list[str]:
return self._members
Caller:
team.members.clear()
This bypasses controlled methods.
Alternatives:
@property
def members(self) -> tuple[str, ...]:
return tuple(self._members)
or:
def __iter__(self):
return iter(self._members)
or return a read-only protocol.
Ownership and mutation rights should be intentional.
17. __slots__
class Point:
__slots__ = (
"x",
"y",
)
def __init__(
self,
x: float,
y: float,
) -> None:
self.x = x
self.y = y
Potential benefits:
- reduced per-instance memory;
- prevents arbitrary new attributes;
- can improve attribute access in some cases.
Trade-offs:
- inheritance complexity;
- no normal instance
__dict__unless added; - weak-reference considerations;
- less dynamic behaviour;
- often unnecessary.
Use slots after measuring or when fixed attributes are a deliberate design constraint.
18. Composition versus inheritance
Composition:
class NotificationService:
def __init__(
self,
sender,
) -> None:
self._sender = sender
def notify(
self,
recipient: str,
message: str,
) -> None:
self._sender.send(
recipient,
message,
)
Inheritance:
class EmailNotificationService(
EmailSender
):
...
Composition is often easier because:
- dependencies are explicit;
- components can be replaced;
- tests can inject fakes;
- unrelated concepts are not forced into one hierarchy;
- lifecycle remains clearer.
Use inheritance for genuine substitutability and shared protocol behaviour, not only code reuse.
19. Dataclasses preview
Boilerplate:
class Point:
def __init__(
self,
x: float,
y: float,
) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
...
def __eq__(
self,
other: object,
) -> bool:
...
Dataclass:
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
Dataclasses are useful for data-focused types. They do not replace all classes.
Use normal classes when behaviour, lifecycle, invariants, or custom construction dominate.
20. TypeScript comparison
TypeScript:
class User {
constructor(
public email: string
) {}
}
Python:
class User:
def __init__(
self,
email: str,
) -> None:
self.email = email
Important differences:
- Python attributes can be added dynamically unless restricted;
- annotations do not enforce runtime types;
- methods are descriptors and become bound;
- public attributes are normal;
- properties preserve attribute syntax;
- class bodies execute;
- classes are runtime objects;
- privacy is mostly conventional.
21. Common mistakes
Getter and setter boilerplate
Start with attributes. Add properties when behaviour is needed.
Mutable class attributes
Move them to instances.
Static methods used as class namespaces
Prefer module functions when appropriate.
Double underscores everywhere
Use single-underscore convention for internals.
Inheritance for code reuse
Prefer composition unless substitutability is real.
Returning mutable internal state
Expose immutable views or controlled operations.
Treating annotations as enforcement
Validate runtime boundaries explicitly.
22. English vocabulary
| Term | Meaning |
|---|---|
| instance | object created from a class |
| class attribute | value stored on the class |
| instance attribute | value stored on an instance |
| binding | association of method and instance |
| property | managed attribute access |
| invariant | condition that must always remain true |
| encapsulation | controlling access to state and behaviour |
| name mangling | rewriting double-underscore attribute names |
| alternative constructor | named class method creating instances |
| composition | building behaviour from collaborating objects |
Useful sentences:
- “The mutable collection must be initialized per instance.”
- “A property preserves the public attribute syntax.”
- “The class method acts as an alternative constructor.”
- “Name mangling prevents accidental subclass collisions.”
- “Composition makes the dependency explicit.”
- “The returned list exposes internal mutable state.”
23. Speaking task
Explain for eight minutes:
How Python classes differ from TypeScript classes.
Discuss attributes, methods, privacy, properties, class objects, and composition.
24. Writing task
Write a 350-word refactoring proposal for a Python codebase that uses getters, setters, abstract base classes, and static utility classes everywhere.
25. Exercises
Exercise 1
Implement a Temperature class with a validated Celsius property.
Exercise 2
Create a User alternative constructor from a dictionary.
Exercise 3
Fix a Team class whose members list is shared by every instance.
Exercise 4
Design a ShoppingCart that does not expose its mutable item list.
Exercise 5
Create a callable factory receiving a class and returning constructed instances from validated raw input.
26. Complete solutions
Solution 1
class Temperature:
def __init__(
self,
celsius: float,
) -> None:
self.celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(
self,
value: float,
) -> None:
if value < -273.15:
raise ValueError(
"Temperature below "
"absolute zero"
)
self._celsius = float(value)
@property
def fahrenheit(self) -> float:
return (
self._celsius * 9 / 5
+ 32
)
Solution 2
class User:
def __init__(
self,
email: str,
name: str,
) -> None:
self.email = email
self.name = name
@classmethod
def from_mapping(
cls,
data: dict[str, object],
) -> "User":
email = data.get("email")
name = data.get("name")
if not isinstance(email, str):
raise ValueError(
"email must be a string"
)
if not isinstance(name, str):
raise ValueError(
"name must be a string"
)
return cls(
email=email.strip().lower(),
name=name.strip(),
)
Solution 3
class Team:
def __init__(self) -> None:
self.members: list[str] = []
Each instance now owns a separate list.
Solution 4
class ShoppingCart:
def __init__(self) -> None:
self._items: list[str] = []
def add(
self,
item: str,
) -> None:
self._items.append(item)
def remove(
self,
item: str,
) -> None:
self._items.remove(item)
@property
def items(self) -> tuple[str, ...]:
return tuple(self._items)
Solution 5
from collections.abc import Callable
from typing import TypeVar
T = TypeVar("T")
def create_factory(
cls: Callable[..., T],
) -> Callable[
[dict[str, object]],
T,
]:
def factory(
data: dict[str, object],
) -> T:
name = data.get("name")
if not isinstance(name, str):
raise ValueError(
"name must be a string"
)
return cls(
name=name.strip()
)
return factory
In production, a protocol or more precise parameter specification may improve typing.
27. Chapter checkpoint
You should now be able to explain:
- classes as runtime objects;
- instance creation;
- method binding;
- instance versus class attributes;
- mutable class-attribute bugs;
- public attributes and properties;
- name-mangling limits;
- class and static methods;
- invariants;
- composition versus inheritance.