Zum Hauptinhalt springen

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:

  1. calling the class object;
  2. allocating an instance with __new__;
  3. initializing it with __init__;
  4. 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:

  1. data descriptors on the class;
  2. instance dictionary;
  3. non-data descriptors and ordinary class attributes;
  4. base classes;
  5. __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

TermMeaning
instanceobject created from a class
class attributevalue stored on the class
instance attributevalue stored on an instance
bindingassociation of method and instance
propertymanaged attribute access
invariantcondition that must always remain true
encapsulationcontrolling access to state and behaviour
name manglingrewriting double-underscore attribute names
alternative constructornamed class method creating instances
compositionbuilding 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:

  1. classes as runtime objects;
  2. instance creation;
  3. method binding;
  4. instance versus class attributes;
  5. mutable class-attribute bugs;
  6. public attributes and properties;
  7. name-mangling limits;
  8. class and static methods;
  9. invariants;
  10. composition versus inheritance.