- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
A class should only have one responsibility. Do exactly one thing.
- In React, break out react components into single responsibility reusable components
- Usually items you map over
- When components have a
useState
anduseEffect
, refactor it into a custom hook
Example:
// Without SRP
class UserManagement {
registerUser(user) {
/* ... */
}
loginUser(user) {
/* ... */
}
generateReports(user) {
/* ... */
}
}
// With SRP
class UserManager {
registerUser(user) {
/* ... */
}
loginUser(user) {
/* ... */
}
}
class ReportGenerator {
generateReports(user) {
/* ... */
}
}
Software entities (classes, modules, functions) should be open for extension but closed for modification. You should be able to extend the behaviour of a class without changing it's source code
- In React, props should accept more generic values and not specific implementations
- Accept an icon prop as opposed to a role prop (back, forward, main) that determines an icon
Example:
// Without OCP
class Circle {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
area() {
return Math.PI * this.radius * this.radius;
}
}
// Extending the Circle class
class ColoredCircle extends Circle {
color: string;
constructor(radius: number, color: string) {
super(radius);
this.color = color;
}
// Violates OCP because we had to modify the source code
area() {
return super.area() + ` of color ${this.color}`;
}
}
// With OCP
interface Shape {
area(): number;
}
class Circle implements Shape {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
area() {
return Math.PI * this.radius * this.radius;
}
}
class ColoredCircle implements Shape {
circle: Circle;
color: string;
constructor(circle: Circle, color: string) {
this.circle = circle;
this.color = color;
}
area() {
return this.circle.area() + ` of color ${this.color}`;
}
}
Subtype objects should be substitutable for supertype objects. In TypeScript, subclasses should not violate the expected behavior of the base class.
- In React, pass in props to the underlying parent component
- className should be passed through into the underlying component
- ...props should be passed into the underlying component
- forwardRef should be passed into the underlying component
Example:
// Violating LSP
class Bird {
fly() {
/* ... */
}
}
class Ostrich extends Bird {
fly() {
throw new Error("Ostriches can't fly");
}
}
// With LSP
class Bird {
feathers: number;
beakColor: string;
}
// All flying birds
class Neornithes extends Bird {
fly() {}
}
// Non-flying birds
class Ratite extends Bird {}
class Ostrich extends Ratite {}
Clients should not depend upon interfaces that they don't use. It promotes creating small, specific interfaces rather than large monolithic ones.
- In React, components shouldn't depend on props it doesn't use
- Don't pass a whole object when you only need some parameters for the component
Example:
// Violating ISP
interface Worker {
work(): void;
eat(): void;
}
class Engineer implements Worker {
work() {
/* ... */
}
eat() {
/* ... */
}
}
class Manager implements Worker {
work() {
/* ... */
}
eat() {
/* ... */
}
}
// With ISP
interface Workable {
work(): void;
}
interface Feedable {
eat(): void;
}
class Engineer implements Workable, Feedable {
work() {
/* ... */
}
eat() {
/* ... */
}
}
class Manager implements Workable {
work() {
/* ... */
}
}
An entity should depend upon abstractions, not concretions. In TypeScript, this often involves using interfaces and dependency injection.
- In React, make a react component a standalone component and able to extend
- Passing in handleSubmit into a component, as opposed to writing it in the component. Calling it
ConnectedForm
that has the handleSubmit and the Form component
- Passing in handleSubmit into a component, as opposed to writing it in the component. Calling it
Example:
// Without DIP
class LightBulb {
turnOn() {
/* ... */
}
turnOff() {
/* ... */
}
}
class Switch {
bulb: LightBulb;
constructor(bulb: LightBulb) {
this.bulb = bulb;
}
toggle() {
if (this.bulb.isOn) {
this.bulb.turnOff();
} else {
this.bulb.turnOn();
}
}
}
// With DIP
interface Switchable {
turnOn(): void;
turnOff(): void;
}
class LightBulb implements Switchable {
turnOn() {
/* ... */
}
turnOff() {
/* ... */
}
}
class Switch {
device: Switchable;
constructor(device: Switchable) {
this.device = device;
}
toggle() {
if (this.device.isOn) {
this.device.turnOff();
} else {
this.device.turnOn();
}
}
}
Source: https://www.youtube.com/watch?v=HsWKyERYGKQ
- Writing functions requires changing the function each time it isn't compatible with another function
- Writing objects require much more thoughtfulness upfront but allows for extending functionality and ideally only writing it once (or a few times only)
- Protect your variables and methods appropriately using the
private
orprotected
namesprivate
- Can't be changed by external client, can't be accessed by a subclassprotected
- Can't be changed by external client, can be accessed by a subclass
// Not private
class Player {
health: number
speed: number
}
const mario = new Player()
mario.health = -8
mario.speed = 1
// Private variables
class Player {
private health: number
setHealth(health: number) {
if (health < 0) {
console.log("You can't set the health below 0");
return;
}
this.health = health
}
getHealth() {
return this.health
}
}
const mario = new Player();
mario.setHealth(10)
- Avoids code duplication
- Making sure class hierarchies make sense
- IS-A - Used to determine if a class should extend another. Ex. Dog is an Animal
- HAS-A - Used to determine if a class should have a variable. Ex. Animals have coordinates, Dog has a owner
class Animal() {
protected coordX: number
protected coordY: number
}
// Dog IS-A Animal
class Dog extends Animal {
owner: string; // Dog HAS-A owner
returnToOwner() {
console.log(`I'm at (${this.coordX}, ${this.coordY})`)
}
}
- You can override the super class method or extend it by called
super.method()
class Animal() {
protected coordX: number
protected coordY: number
makeNoise() {
console.log("Make noise");
}
move() {
console.log(`I'm moving from coord (${this.coordX}, ${this.coordY})`)
}
}
class Canine extends Animal {}
class Dog extends Canine {
// Overriding the super class method
makeNoise() {
console.log("bark bark bark");
}
// Calling the super class method
move() {
console.log("getting on all four paws...")
super.move(); // I'm moving from coord(_,_)
}
}
class Wolf extends Canine {}
// dog can access methods in Animal and Canine
const dog = new Dog();
Inheritance
- Removes code duplication
- Providing a common protocol for a group of subclasses (ie., polymorphism)
- A subclass is the type of the super class in typescript (ie., an archer is a Hero.
archer:Hero = new Archer()
)- Poly = many
- Morph = forms
- So a hero can be many forms (archer, mage, knight)
class Hero {
hunger: number;
health: number
attack() {
console.log("I'm attacking")
}
move() {
console.log("I'm moving")
}
eat() {
console.log("I'm eating")
}
}
class Archer extends Hero {
arrows: number
attack() {
super.attack()
console.log("Firing an arrow")
this.arrows -= 1;
}
}
class Mage extends Hero {
mana: number;
attack() {
super.attack();
console.log("Throwing a potion")
this.mana -= 1;
}
}
class Knight extends Hero {
sword: number
attack() {
super.attack();
console.log("I'm swinging with a sword")
}
}
// Since an archer extends a hero, an archer IS-A hero
const archer: Hero = new Archer();
const mage: Hero = new Mage();
const knight: Hero = new Knight();
archer.attack();
mage.attack();
knight.attack();
class Tribe {
private heros: Hero[]
setHeros(heros: Hero[]) {
this.heros = heros
}
// This method can depend on the fact that the hero is of type Hero and has an attack method
attack(): void {
for (let hero of this.heros) {
hero.attack();
}
}
}
const heros: Hero[] = [arhcer, mage, knight]
const tribe = new Tribe()
tribe.setHeros(heros)
tribe.attack()
- Weeks later, you can create a new
Thief
hero and add it to the tribe as a new set of heros and it'll still work by extending the functionality as opposed to modifying it
abstract
Restrict a class from being instantiated. Allows inheritance when extended- abstract methods - Method MUST be implemented in concrete classes. Need to be overridded.
- concrete classes - Classes that can be instantiated
- abstract methods must be in abstract classes - since if you extend a class with an abstract method, the method isn't implemented
- abstract classes can extend other abstract classes without implemented the abstract method
abstract class Hero {
abstract attack(): void;
move(): void {
console.log("I'm moving");
}
}
class Archer extends Hero {
attack() {
console.log("Firing an arrow");
}
}
const archer: Archer = new Archer();
const knight: Knight = new Knight();
const bob: Hero = new Hero();
// Abstract classes can extend other abstract classes
abstract class Mage extends Hero {
mana: number;
}
class Wizard extends Mage {
attack() {
this.mana -= 1;
console.log("Wizard attacks")
}
}
class Witch extends Mage {
attack() {
this.mana -= 1;
console.log("Witch attacks")
}
}
- Classes cannot extend multiple classes - since methods may clash
interface
- A subclass can extend multiple interface classesimplements
- Keyword used to implement an interface- Using multiple inheritance for polymorphism of multiple classes trades off inheritance from the super class
The example below doesn't work:
class Character {
hunger: number;
health: number;
}
class Hero extends Character {
heroId: number;
eat() {
this.hunder += 3;
}
}
class Enemy extends Character {
enemyId: number;
eat() {
this.hunder += 1;
}
}
// Does NOT work
class Spy extends Hero, Enemy{}
The example below DOES work:
// DOES work
abstract class Character {
hunger: number;
health: number;
abstract eat(): void;
}
interface Hero extends Character {
heroId: number;
}
interface Enemy extends Character {
enemyId: number;
}
class Spy implements Hero, Enemy{
// Need to implement hunder and health yourself since Character is an abstract class
hunger: number;
health: number;
heroId: number;
enemyId: number;
eat() {
this.hunger -= 1;
}
}
const hero: Hero = new Spy();
const enemy: Enemy = new Spy();
- Basic classes
- When it doesn't pass any IS-A test or there is no super class
- Subclasses
- When your class IS-A existing class
- Abstract classes
- To create a template for other classes, but don't want that class to be instantiated
- Interfaces
- Need multiple types for Polymorphic reasons
// 1. No other classes. Doesn't pass any IS-A test
class Character {}
// 2. Knight and Archer IS-A character
class Knight extends Character {}
class Archer extends Character {}
// 3. Abstract classes - template for other classes, but don't want Mage to be instantiated
abstract class Mage extends Character {}
class Wizard extends Character {}
class Witch extends Character {}
// 4. Interfaces - Implementing multiple interfaces
interface Hero extends Character {}
interface Enemy extends Character {}
class Spy implements Hero, Enemy {}
- Typescript parameter properties - Ability to define the constructor variables inline
constructor(public hunger: number, public health: number) {}
static
- variables and methods that can only be called from the Class itself and not an instance of the class- static variables are like shared state amongst objects
readonly
- cannot modify it after it's been instantiated
class Character {
// Lives on the class level not on the instance level
// Can call Character.characterCount
static characterCount = 0;
private hunger: number;
private health: number;
constructor(hunger: number, health: number) {
Character.characterCount += 1;
console.log(`I'm the ${Character.characterCount} character created`);
this.hunger = hunger;
this.health = health;
}
setHunger(hunger: number): void {
this.hunger = hunger;
}
setHealth(health: number): void {
this.health = health;
}
getHunger(): number {
return this.hunger;
}
getHealth(): number {
return this.health;
}
}
class Hero extends Character {
private heroId: number;
// Cannot mutate this variable outside of the constructor
private readonly wealth: number;
constructor(heroId: number, hunger: number, health: number) {
// Need to call the super class constructor. Similar to calling `new Character()`
super(hunger, health)
this.heroId = heroId;
}
setHeroId(heroId: number): void {
this.heroId = heroId;
}
getHeroId(): number {
return this.heroId;
}
}
const jeff = new Character(100,100);
Typescript parameter properties
// Less typing, but more difficult to understand
class Character {
constructor(public hunger: number, public health: number) {}
}
// same as
class Character {
public hunger: number;
public health: number;
constructor(hunger: number, health: number) {
this.hunger = hunger;
this.health = health;
}
}