Skip to content
Commitwise
Menu
Programming

Classes, inheritance and interfaces

Bundle data and behavior in classes that protect their own rules, describe abilities with interfaces and protocols, and use inheritance sparingly, with the dispatch and initialization traps that differ between Swift, Kotlin, Java and TypeScript.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A bank account with a public balance field works perfectly until someone writes account.balance = -500 from a screen that was only supposed to display it. Classes exist so that data comes with the rules that keep it valid. Interfaces let different classes promise the same abilities, and inheritance lets one class build on another, which is the most powerful of the three and the easiest to regret.

New to some of these? Open what you need, or skip ahead.

Basics:What are variables and types?

A variable is a named box that holds a value: let age = 30 (Swift), val age = 30 (Kotlin), int age = 30; (Java), const age = 30 (TypeScript). The type says what kind of value fits in the box: Int / int for whole numbers, Double for decimals, String for text, Bool / Boolean for true or false.

  • let (Swift), val (Kotlin) and final (Java) mean the variable can’t be pointed at another value later. var means it can.
  • Swift, Kotlin and TypeScript can often infer the type from the value, so you don’t have to write it. Java needs it written (or var for local variables). TypeScript’s const is like let / val, and its let is like var.
Basics:What are functions?

A function is a named piece of code that takes parameters and can return a result: func add(_ a: Int, _ b: Int) -> Int (Swift), fun add(a: Int, b: Int): Int (Kotlin), int add(int a, int b) (Java), function add(a: number, b: number): number (TypeScript). A function that belongs to a class is called a method. The Functions tutorial covers them in detail.

Part Not started

Classes: data with rules

Think of a vending machine. You can’t reach in and take a drink, and you can’t set the machine’s cash box to 1,000 EUR. You press buttons (insertCoin, choose), and the machine decides what’s allowed. Its insides are hidden; only the buttons are public.

A class works the same way. It bundles data (properties) with methods that work on that data, and it decides which parts the outside world may touch. Hiding the insides is called encapsulation.

A class is a type with identity, state and behavior; its job is to maintain invariants (rules that are always true for a valid object) across every public operation. Encapsulation is how: the constructor establishes the invariant, private state prevents bypassing it, and each public method preserves it. Swift and Kotlin default to fewer ways to break this (Kotlin classes are final by default; Swift encourages private(set) and value types for plain data); Java and TypeScript need more explicit discipline.

final class Account {
    let owner: String
    private(set) var balance: Int = 0          // readable outside, changeable only inside

    init(owner: String) {
        self.owner = owner
    }

    func deposit(_ amount: Int) {
        precondition(amount > 0, "Deposit must be positive")
        balance += amount
    }

    func withdraw(_ amount: Int) -> Bool {
        guard amount > 0, amount <= balance else { return false }
        balance -= amount
        return true
    }
}
class Account(val owner: String) {
    var balance: Int = 0                        // readable outside, changeable only inside
        private set

    fun deposit(amount: Int) {
        require(amount > 0) { "Deposit must be positive" }
        balance += amount
    }

    fun withdraw(amount: Int): Boolean {
        if (amount <= 0 || amount > balance) return false
        balance -= amount
        return true
    }
}
public final class Account {
    private final String owner;
    private int balance = 0;                    // hidden; read through a getter

    public Account(String owner) {
        this.owner = owner;
    }

    public String owner() { return owner; }
    public int balance() { return balance; }

    public void deposit(int amount) {
        if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive");
        balance += amount;
    }

    public boolean withdraw(int amount) {
        if (amount <= 0 || amount > balance) return false;
        balance -= amount;
        return true;
    }
}
class Account {
  #balance = 0;                                 // # makes it truly private at runtime

  constructor(readonly owner: string) {}

  get balance(): number {
    return this.#balance;
  }

  deposit(amount: number): void {
    if (amount <= 0) throw new Error("Deposit must be positive");
    this.#balance += amount;
  }

  withdraw(amount: number): boolean {
    if (amount <= 0 || amount > this.#balance) return false;
    this.#balance -= amount;
    return true;
  }
}

The pieces:

  • Properties hold the data: owner and balance.
  • The initializer / constructor (init, the parentheses after class Account in Kotlin, the method named like the class in Java, constructor in TypeScript) runs when you create an object: Account(owner: "Ana"), Account("Ana"), new Account("Ana").
  • Methods are the buttons: deposit and withdraw check the rules before changing anything.
  • Access control: balance can be read from outside but only changed inside the class. No other code can make it negative.
Spot the bugA balance anyone can change

Tap the line with the bug.

final class Account {
    var balance: Int = 0

    func withdraw(_ amount: Int) -> Bool {
        guard amount <= balance else { return false }
        balance -= amount
        return true
    }
}
class Account {
    var balance: Int = 0

    fun withdraw(amount: Int): Boolean {
        if (amount > balance) return false
        balance -= amount
        return true
    }
}
public class Account {
    public int balance = 0;

    public boolean withdraw(int amount) {
        if (amount > balance) return false;
        balance -= amount;
        return true;
    }
}
class Account {
  balance = 0;

  withdraw(amount: number): boolean {
    if (amount > this.balance) return false;
    this.balance -= amount;
    return true;
  }
}

Computed properties

Some values don’t need to be stored at all, because they can be calculated from other properties every time they’re read:

struct Rectangle {
    var width: Double
    var height: Double
    var area: Double { width * height }      // computed on every read
}
class Rectangle(var width: Double, var height: Double) {
    val area: Double get() = width * height  // computed on every read
}
record Rectangle(double width, double height) {
    double area() { return width * height; } // a method computes it
}
class Rectangle {
  constructor(public width: number, public height: number) {}
  get area(): number { return this.width * this.height; }   // computed on every read
}

A computed value can never be out of date: change the width, and the area is right the next time anyone reads it. Storing area as a separate property would mean remembering to update it every time.

Classes, structs, records, data classes

For data without behavior or identity, the class is often the wrong tool:

NeedSwiftKotlinJavaTypeScript
Plain data, compared by valuestructdata class (immutable vals)recordtype/interface + object literal, readonly
Identity and mutable state with rulesfinal classclassfinal classclass with #private fields
A single shared instancestatic let shared / enum namespaceobjectenum singleton or DI-managed beanmodule-level const

Prefer the lightest construct that fits: values for data, classes when there’s an invariant to protect or identity matters. Value types vs reference types covers the copy-vs-share consequences.

Deep diveTypeScript private vs #privateInterview favorite

TypeScript’s private keyword is checked only by the compiler; at runtime it’s an ordinary property, visible in JSON.stringify, in the debugger, and to any JavaScript caller ((obj as any).balance = -1 works). #balance is a JavaScript private field: enforced by the runtime, invisible to Object.keys and serialization, and inaccessible from outside the class body. Use # when the field protects an invariant or holds a secret; private is fine for internal convenience.

  • Classes exist to maintain invariants: the constructor establishes them, private state prevents bypass, every public method preserves them (including argument validation).
  • Read-only from outside: Swift private(set), Kotlin private set, Java private field + getter, TypeScript #field + getter (private is compile-time only).
  • Use struct / data class / record / object types for plain data; classes for identity and guarded state.

Check this part

  1. Why make balance changeable only inside the class?

  2. What runs when you create a new object?

  3. How do you expose a property as read-only outside the type in Swift?How do you expose a property as read-only outside the class in Kotlin?What does a Java record give you, and what doesn't it?What is the difference between TypeScript private x and #x?

A class bundles data (properties) with the methods that work on it, and an initializer that sets it up. Keep state private and change it only through methods that check the rules, so an object can never be in an invalid state.

Part Not started

Interfaces and protocols: promises of ability

A wall socket doesn’t care whether you plug in a lamp, a phone charger or a kettle. It only cares that the plug has the right shape. Anything with that plug works.

An interface (Kotlin, Java, TypeScript) or protocol (Swift) is the shape of the plug: a list of methods a type promises to have. Code written against the interface works with any type that keeps the promise, including ones written later, and including a fake one in tests.

Interfaces define capabilities and decouple callers from implementations. Swift protocols and Kotlin/Java interfaces are nominal (a type conforms by declaring it); TypeScript interfaces are structural (any object with the right shape conforms, declared or not). All four support default implementations (Swift protocol extensions, Kotlin interface bodies, Java default methods; TypeScript only via abstract classes or mixins), and Swift additionally supports retroactive conformance of existing types via extensions.

protocol Notifier {
    var name: String { get }
    func send(_ message: String, to user: User)
}

struct EmailNotifier: Notifier {
    let name = "Email"
    func send(_ message: String, to user: User) { print("Email to \(user.email): \(message)") }
}

struct PushNotifier: Notifier {
    let name = "Push"
    func send(_ message: String, to user: User) { print("Push to \(user.id): \(message)") }
}

func notifyAll(_ notifiers: [any Notifier], user: User, message: String) {
    for notifier in notifiers { notifier.send(message, to: user) }   // works for any Notifier
}
interface Notifier {
    val name: String
    fun send(message: String, user: User)
}

class EmailNotifier : Notifier {
    override val name = "Email"
    override fun send(message: String, user: User) = println("Email to ${user.email}: $message")
}

class PushNotifier : Notifier {
    override val name = "Push"
    override fun send(message: String, user: User) = println("Push to ${user.id}: $message")
}

fun notifyAll(notifiers: List<Notifier>, user: User, message: String) {
    for (notifier in notifiers) notifier.send(message, user)       // works for any Notifier
}
interface Notifier {
    String name();
    void send(String message, User user);
}

class EmailNotifier implements Notifier {
    public String name() { return "Email"; }
    public void send(String message, User user) { System.out.println("Email to " + user.email() + ": " + message); }
}

class PushNotifier implements Notifier {
    public String name() { return "Push"; }
    public void send(String message, User user) { System.out.println("Push to " + user.id() + ": " + message); }
}

void notifyAll(List<Notifier> notifiers, User user, String message) {
    for (Notifier notifier : notifiers) notifier.send(message, user);   // works for any Notifier
}
interface Notifier {
  readonly name: string;
  send(message: string, user: User): void;
}

class EmailNotifier implements Notifier {
  readonly name = "Email";
  send(message: string, user: User) { console.log(`Email to ${user.email}: ${message}`); }
}

const pushNotifier: Notifier = {                     // any object with the right shape works
  name: "Push",
  send: (message, user) => console.log(`Push to ${user.id}: ${message}`),
};

function notifyAll(notifiers: Notifier[], user: User, message: string) {
  for (const notifier of notifiers) notifier.send(message, user);   // works for any Notifier
}

notifyAll doesn’t know about email or push; it only knows the promise, send. Adding SMS means writing one new type, and notifyAll keeps working unchanged. In a test, you’d pass a FakeNotifier that just records the messages. This idea, depending on the interface rather than a specific class, comes back in Dependency injection and the SOLID principles.

Predict the output

What does this print?

let notifiers: [any Notifier] = [EmailNotifier(), PushNotifier()]
for n in notifiers { print(n.name) }
val notifiers: List<Notifier> = listOf(EmailNotifier(), PushNotifier())
for (n in notifiers) println(n.name)
List<Notifier> notifiers = List.of(new EmailNotifier(), new PushNotifier());
for (Notifier n : notifiers) System.out.println(n.name());
const notifiers: Notifier[] = [new EmailNotifier(), pushNotifier];
for (const n of notifiers) console.log(n.name);

Default methods

An interface can also provide a ready-made method, built on top of its required ones. Every implementation gets it for free, and can replace it if needed:

extension Notifier {
    func sendAll(_ message: String, to users: [User]) {
        for user in users { send(message, to: user) }
    }
}
interface Notifier {
    val name: String
    fun send(message: String, user: User)
    fun sendAll(message: String, users: List<User>) = users.forEach { send(message, it) }
}
interface Notifier {
    String name();
    void send(String message, User user);
    default void sendAll(String message, List<User> users) {
        for (User user : users) send(message, user);
    }
}
// Interfaces can't contain code; share it with a plain function (or an abstract class).
function sendAll(notifier: Notifier, message: string, users: User[]): void {
  for (const user of users) notifier.send(message, user);
}

Dispatch differs

Predict the output

What does this print?

protocol Notifier { func send(_ message: String, to user: User) }
extension Notifier {
    func describe() -> String { "One by one" }
}
struct BatchNotifier: Notifier {
    func send(_ message: String, to user: User) {}
    func describe() -> String { "Batch" }
}

let n: any Notifier = BatchNotifier()
print("\(n.describe()): 2 users")
interface Notifier {
    fun send(message: String, user: User)
    fun describe(): String = "One by one"
}
class BatchNotifier : Notifier {
    override fun send(message: String, user: User) {}
    override fun describe() = "Batch"
}

val n: Notifier = BatchNotifier()
println("${n.describe()}: 2 users")
interface Notifier {
    void send(String message, User user);
    default String describe() { return "One by one"; }
}
class BatchNotifier implements Notifier {
    public void send(String message, User user) {}
    public String describe() { return "Batch"; }
}

Notifier n = new BatchNotifier();
System.out.println(n.describe() + ": 2 users");
abstract class Notifier {
  abstract send(message: string, user: User): void;
  describe(): string { return "One by one"; }
}
class BatchNotifier extends Notifier {
  send(message: string, user: User) {}
  describe(): string { return "Batch"; }
}

const n: Notifier = new BatchNotifier();
console.log(`${n.describe()}: 2 users`);

Interface design notes

  • Structural typing (TypeScript) means any object with matching members conforms, including by accident: two interfaces with the same shape are interchangeable. Brand types ({ readonly __brand: "UserId" }) restore nominal behavior where it matters.
  • Existentials in Swift: any Notifier is a boxed existential with dynamic dispatch and an allocation for large values; some Notifier or generics keep the concrete type for static dispatch. Prefer generics in hot paths.
  • Evolving interfaces: adding a method breaks every implementer unless it has a default. That’s why Java added default methods (to evolve Collection in Java 8) and why public SDK protocols are hard to change.
  • Swift/Kotlin/Java interfaces are nominal; TypeScript’s are structural (shape-based, conformance can be accidental; brand when needed).
  • Defaults: Swift protocol extensions, Kotlin interface bodies, Java default; TypeScript via abstract classes or functions.
  • Swift gotcha: members declared only in a protocol extension are statically dispatched; implementations in conforming types are ignored through any P. Declare customization points in the protocol body.
  • any P (existential, dynamic dispatch, possible allocation) vs some P / generics (static).

Check this part

  1. What does an interface (protocol) define?

  2. A list typed as Notifier holds an EmailNotifier and a PushNotifier. Calling send on each runs…

  3. A method exists only in a protocol extension, and a conforming type defines the same method. Calling it through any P runs…An interface default method is overridden in a class. Calling it through the interface type runs…A default method is overridden in a class. Calling it through the interface type runs…TypeScript interfaces conform by shape. What risk does that create?

An interface (protocol) lists what a type can do without saying how. Code that depends on the interface works with any implementation: card, PayPal, or a fake in tests. Interfaces can provide default methods.

Part Not started

Inheritance: building on a class

class Account {
    private(set) var balance = 0
    func deposit(_ amount: Int) { balance += amount }
    func monthlyFee() -> Int { 2 }
}

final class SavingsAccount: Account {
    override func monthlyFee() -> Int { 0 }          // replaces the parent's version
    func addInterest(rate: Double) {
        deposit(Int(Double(balance) * rate))         // uses inherited methods
    }
}
open class Account {                                 // open: may be subclassed
    var balance = 0
        private set
    fun deposit(amount: Int) { balance += amount }
    open fun monthlyFee(): Int = 2                   // open: may be overridden
}

class SavingsAccount : Account() {
    override fun monthlyFee(): Int = 0               // replaces the parent's version
    fun addInterest(rate: Double) {
        deposit((balance * rate).toInt())            // uses inherited methods
    }
}
class Account {
    private int balance = 0;
    public int balance() { return balance; }
    public void deposit(int amount) { balance += amount; }
    public int monthlyFee() { return 2; }
}

final class SavingsAccount extends Account {
    @Override public int monthlyFee() { return 0; }  // replaces the parent's version
    public void addInterest(double rate) {
        deposit((int) (balance() * rate));           // uses inherited methods
    }
}
class Account {
  #balance = 0;
  get balance(): number { return this.#balance; }
  deposit(amount: number): void { this.#balance += amount; }
  monthlyFee(): number { return 2; }
}

class SavingsAccount extends Account {
  override monthlyFee(): number { return 0; }        // replaces the parent's version
  addInterest(rate: number): void {
    this.deposit(Math.floor(this.balance * rate));   // uses inherited methods
  }
}
  • SavingsAccount inherits everything Account has: the balance and deposit. It’s a subclass; Account is its superclass (parent).
  • Override replaces a parent’s method with a new version. super.monthlyFee() would call the parent’s version, if you want to build on it instead of replacing it.
  • A SavingsAccount can be used anywhere an Account is expected.
  • Kotlin classes and methods are closed by default: the parent must say open. Swift and Java allow subclassing unless the class is final. Making classes final unless they’re designed to be extended prevents surprises.

Inheritance gives code reuse and subtype polymorphism in one mechanism, and couples the subclass to the parent’s implementation details (the fragile base class problem). Defaults reflect how each language weighs that: Kotlin closes classes and members by default, Swift’s final (and public vs open across modules) makes extension points explicit, Java and TypeScript leave everything overridable. For reuse alone, prefer composition and delegation (Kotlin by); keep inheritance for genuine is-a relationships with a stable, designed-for-extension base. The SOLID lesson’s Liskov section shows what happens when the is-a claim doesn’t hold.

Exerciseeasy

Accounts with different rules

Using the Account above, add a CheckingAccount subclass with an overdraft limit: it may go down to -limit (say -200), while a plain Account can’t go below zero. Make withdraw overridable in the parent, and override it in the subclass. Then write a function chargeFees(accounts) that withdraws each account’s monthlyFee().

›

The parent’s withdraw checks amount <= balance. The subclass’s version checks amount <= balance + limit. Both need to change the balance, so the parent should offer a protected (or internal) way to do it, or the subclass reuses super.withdraw where it can.

›

chargeFees takes a list of Account. Thanks to overriding, each account uses its own withdraw and monthlyFee, without any if on the type.

SolutionTry it yourself first
class Account {
    fileprivate(set) var balance = 0
    func deposit(_ amount: Int) { balance += amount }
    func monthlyFee() -> Int { 2 }
    func withdraw(_ amount: Int) -> Bool {
        guard amount > 0, amount <= balance else { return false }
        balance -= amount
        return true
    }
}

final class CheckingAccount: Account {
    let limit: Int
    init(limit: Int) { self.limit = limit }

    override func withdraw(_ amount: Int) -> Bool {
        guard amount > 0, amount <= balance + limit else { return false }
        balance -= amount                       // fileprivate(set): same file
        return true
    }
}

func chargeFees(_ accounts: [Account]) {
    for account in accounts { _ = account.withdraw(account.monthlyFee()) }
}
open class Account {
    var balance = 0
        protected set
    fun deposit(amount: Int) { balance += amount }
    open fun monthlyFee(): Int = 2
    open fun withdraw(amount: Int): Boolean {
        if (amount <= 0 || amount > balance) return false
        balance -= amount
        return true
    }
}

class CheckingAccount(private val limit: Int) : Account() {
    override fun withdraw(amount: Int): Boolean {
        if (amount <= 0 || amount > balance + limit) return false
        balance -= amount                       // protected set: allowed in a subclass
        return true
    }
}

fun chargeFees(accounts: List<Account>) {
    for (account in accounts) account.withdraw(account.monthlyFee())
}
class Account {
    protected int balance = 0;
    public int balance() { return balance; }
    public void deposit(int amount) { balance += amount; }
    public int monthlyFee() { return 2; }
    public boolean withdraw(int amount) {
        if (amount <= 0 || amount > balance) return false;
        balance -= amount;
        return true;
    }
}

final class CheckingAccount extends Account {
    private final int limit;
    CheckingAccount(int limit) { this.limit = limit; }

    @Override public boolean withdraw(int amount) {
        if (amount <= 0 || amount > balance + limit) return false;
        balance -= amount;                      // protected: allowed in a subclass
        return true;
    }
}

void chargeFees(List<Account> accounts) {
    for (Account account : accounts) account.withdraw(account.monthlyFee());
}
class Account {
  protected balanceValue = 0;
  get balance(): number { return this.balanceValue; }
  deposit(amount: number): void { this.balanceValue += amount; }
  monthlyFee(): number { return 2; }
  withdraw(amount: number): boolean {
    if (amount <= 0 || amount > this.balanceValue) return false;
    this.balanceValue -= amount;
    return true;
  }
}

class CheckingAccount extends Account {
  constructor(private readonly limit: number) { super(); }

  override withdraw(amount: number): boolean {
    if (amount <= 0 || amount > this.balanceValue + this.limit) return false;
    this.balanceValue -= amount;            // protected: allowed in a subclass
    return true;
  }
}

function chargeFees(accounts: Account[]): void {
  for (const account of accounts) account.withdraw(account.monthlyFee());
}

Notice the price of inheritance: to let the subclass change the balance, the parent had to open up its state (protected), so the “only methods change the balance” rule now also depends on every subclass behaving. That’s the trade-off the senior notes below are about.

Production puzzles

Predict the output

What does this print?

class Base {
    var message = ""
    init() { message = greeting() }
    func greeting() -> String { "Hello" }
}

final class Child: Base {
    let name: String
    override init() { name = "Ana"; super.init() }
    override func greeting() -> String { "Hello, \(name)" }
}

print(Child().message)
open class Base {
    val message: String = greeting()
    open fun greeting() = "Hello"
}

class Child : Base() {
    private val name: String = "Ana"
    override fun greeting() = "Hello, $name"
}

println(Child().message)
class Base {
    final String message;
    Base() { message = greeting(); }
    String greeting() { return "Hello"; }
}

class Child extends Base {
    private String name = "Ana";
    @Override String greeting() { return "Hello, " + name; }
}

// System.out.println(new Child().message);
class Base {
  readonly message: string;
  constructor() { this.message = this.greeting(); }
  greeting(): string { return "Hello"; }
}

class Child extends Base {
  private readonly name = "Ana";
  override greeting(): string { return `Hello, ${this.name}`; }
}

console.log(new Child().message);

Java note

One subtlety: had the field been private final String name = "Ana", it would be a compile-time constant, javac would inline the literal into greeting(), and it would print “Hello, Ana”. Any non-constant initializer (= loadName()) brings back “Hello, null”. Code whose correctness depends on that difference is exactly the code to avoid.

Exercisemedium

Replace a class hierarchy

A notification module grew by subclassing: Notification → EmailNotification, SmsNotification, PushNotification, then UrgentEmailNotification, UrgentSmsNotification, ScheduledEmailNotification, ScheduledUrgentPushNotification… Every new combination of channel × urgency × timing is another class. Redesign it so each dimension varies independently, and show how “a scheduled urgent push” is built.

›

Each dimension is a separate decision. Make each one its own small type, and let a notification have one of each instead of being a combination.

SolutionTry it yourself first
protocol Channel { func deliver(_ text: String, to user: User) async throws }
enum Urgency { case normal, urgent }
enum Timing { case now, at(Date) }

struct Notification {
    let text: String
    let channel: any Channel          // has a channel instead of being one
    let urgency: Urgency
    let timing: Timing
}

let n = Notification(text: "Gate changed", channel: PushChannel(), urgency: .urgent, timing: .at(boarding))
fun interface Channel { suspend fun deliver(text: String, user: User) }
enum class Urgency { NORMAL, URGENT }
sealed interface Timing { data object Now : Timing; data class At(val time: Instant) : Timing }

data class Notification(
    val text: String,
    val channel: Channel,             // has a channel instead of being one
    val urgency: Urgency,
    val timing: Timing,
)

val n = Notification("Gate changed", PushChannel(), Urgency.URGENT, Timing.At(boarding))
interface Channel { void deliver(String text, User user); }
enum Urgency { NORMAL, URGENT }
sealed interface Timing { record Now() implements Timing {} record At(Instant time) implements Timing {} }

record Notification(String text, Channel channel, Urgency urgency, Timing timing) {}   // has, not is

var n = new Notification("Gate changed", new PushChannel(), Urgency.URGENT, new Timing.At(boarding));
interface Channel { deliver(text: string, user: User): Promise<void>; }
type Urgency = "normal" | "urgent";
type Timing = { kind: "now" } | { kind: "at"; time: Date };

interface Notification {
  text: string;
  channel: Channel;                   // has a channel instead of being one
  urgency: Urgency;
  timing: Timing;
}

const n: Notification = { text: "Gate changed", channel: pushChannel, urgency: "urgent", timing: { kind: "at", time: boarding } };
  • Combinations become values: 3 channels × 2 urgencies × 2 timings was 12 potential classes; now it’s 3 channel types and two small enums. A new channel is one type; a new timing is one case.
  • Behavior lives where it varies: delivery is in Channel (an interface, so tests use a fake); scheduling is handled once, by a scheduler that reads timing; urgency affects policy (quiet hours, sound) in one place.
  • Inheritance remains where it fits: a framework base class you must extend, or a true is-a with a stable base. “Favor composition over inheritance” (Gang of Four) is about exactly this kind of combinatorial hierarchy.
  • Inheritance = reuse + subtyping + coupling to the parent’s internals (fragile base class). Kotlin closes by default (open), Swift has final and open vs public, Java/TypeScript leave everything overridable: mark classes final unless designed for extension.
  • Never call overridable methods from constructors: Kotlin, Java and TypeScript run the base constructor before subclass initializers (the override sees null/undefined); Swift’s two-phase init prevents it.
  • Prefer composition (has-a, delegation, Kotlin by) for reuse and combinatorial variation; keep inheritance for stable is-a relationships.
struct LoggingNotifier: Notifier {
    let inner: any Notifier
    func send(_ m: String, to u: User) { print("sending"); inner.send(m, to: u) }
}
class LoggingNotifier(private val inner: Notifier) : Notifier by inner {
    override fun send(message: String, user: User) { println("sending"); inner.send(message, user) }
}
final class LoggingNotifier implements Notifier {
    private final Notifier inner;
    LoggingNotifier(Notifier inner) { this.inner = inner; }
    public String name() { return inner.name(); }
    public void send(String m, User u) { System.out.println("sending"); inner.send(m, u); }
}
const logging = (inner: Notifier): Notifier => ({
  name: inner.name,
  send: (m, u) => { console.log("sending"); inner.send(m, u); },
});

Check this part

  1. What does override do?

  2. How do you stop a Swift class from being subclassed?Can a Kotlin class be subclassed by default?How do you stop a Java class from being subclassed?Can a TypeScript class prevent subclassing with a keyword?

  3. Why can't a Swift base initializer see an uninitialized subclass property through an override?A base class constructor calls an open method that a subclass overrides. What does the override see?A base class constructor calls an overridable method. What does the override see?A base constructor calls an overridden method. What does it see of the subclass's fields?

  4. Channel × urgency × timing subclasses keep multiplying. The fix?

A subclass inherits a parent class's properties and methods and can override some of them, calling super for the parent's version. Kotlin classes and Swift final classes can't be subclassed unless you allow it. Prefer composition: inheritance couples a subclass to its parent's internals.

Key takeaways

  • A class bundles data with the methods that keep it valid; keep state private and change it through methods.
  • An interface (protocol) is a promise of abilities; code written against it works with any implementation.
  • A subclass inherits and can override; mark classes final (or leave Kotlin’s default) unless they’re meant to be extended.
  • Prefer giving an object the parts it needs (composition) over long chains of subclasses.
  • Classes exist to protect invariants; use value types for plain data.
  • Know dispatch: Swift protocol-extension-only members are static; JVM and TypeScript calls are virtual; TypeScript typing is structural.
  • Constructors must not call overridable members (Kotlin, Java, TypeScript); Swift’s two-phase init guards against it.
  • Inheritance couples to internals; use composition and delegation for reuse and combinatorial variation.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.