L Liskov Substitution Principle LSP
8 min readL — Liskov Substitution Principle (LSP)
"Derived classes/implementations should be substitutable for their base classes/interfaces."
Implementations must behave consistently with their abstraction. Consumers should be able to use any implementation without special handling.
❌ Bad example (React/TypeScript):
interface DataSource {
fetch(): Promise<Data>;
refresh(): Promise<void>;
}
class ApiDataSource implements DataSource {
async fetch(): Promise<Data> {
const response = await fetch('/api/data');
return response.json();
}
async refresh(): Promise<void> {
// Re-fetch data
await this.fetch();
}
}
class StaticDataSource implements DataSource {
async fetch(): Promise<Data> {
return { items: [] }; // Static data
}
async refresh(): Promise<void> {
throw new Error('Static data cannot be refreshed'); // ❌ Violates LSP!
}
}
// Client code breaks when using StaticDataSource
function useData(source: DataSource) {
const handleRefresh = async () => {
await source.refresh(); // This might throw!
};
}
❌ Violates LSP — a StaticDataSource cannot refresh, so substituting it breaks code that expects all DataSource implementations to support refresh.
✅ Good example (React/TypeScript):
// Split into focused interfaces
interface DataSource {
fetch(): Promise<Data>;
}
interface RefreshableDataSource extends DataSource {
refresh(): Promise<void>;
}
class ApiDataSource implements RefreshableDataSource {
async fetch(): Promise<Data> {
const response = await fetch('/api/data');
return response.json();
}
async refresh(): Promise<void> {
await this.fetch();
}
}
class StaticDataSource implements DataSource {
async fetch(): Promise<Data> {
return { items: [] };
}
// No refresh method - doesn't claim to support it
}
// Client code uses appropriate interface
function useData(source: DataSource) {
const isRefreshable = (s: DataSource): s is RefreshableDataSource => {
return 'refresh' in s;
};
const handleRefresh = async () => {
if (isRefreshable(source)) {
await source.refresh();
}
};
}
✅ Better example with proper abstraction (React):
// Base behavior
interface Storage {
get(key: string): string | null;
set(key: string, value: string): void;
}
class LocalStorage implements Storage {
get(key: string): string | null {
return localStorage.getItem(key);
}
set(key: string, value: string): void {
localStorage.setItem(key, value);
}
}
class SessionStorage implements Storage {
get(key: string): string | null {
return sessionStorage.getItem(key);
}
set(key: string, value: string): void {
sessionStorage.setItem(key, value);
}
}
class MemoryStorage implements Storage {
private store = new Map<string, string>();
get(key: string): string | null {
return this.store.get(key) ?? null;
}
set(key: string, value: string): void {
this.store.set(key, value);
}
}
// All implementations are perfectly substitutable
function useStorage(storage: Storage) {
const save = (key: string, value: string) => {
storage.set(key, value); // Works with any implementation
};
const load = (key: string) => {
return storage.get(key); // Works with any implementation
};
return { save, load };
}
✅ Good example (Angular):
// Base abstraction
export abstract class Logger {
abstract log(message: string): void;
abstract error(message: string): void;
}
// All implementations honor the contract
@Injectable()
export class ConsoleLogger extends Logger {
log(message: string): void {
console.log(message);
}
error(message: string): void {
console.error(message);
}
}
@Injectable()
export class RemoteLogger extends Logger {
constructor(private http: HttpClient) {
super();
}
log(message: string): void {
this.http.post('/api/logs', { level: 'info', message }).subscribe();
}
error(message: string): void {
this.http.post('/api/logs', { level: 'error', message }).subscribe();
}
}
// Silent logger for tests - still honors contract
@Injectable()
export class SilentLogger extends Logger {
log(message: string): void {
// No-op, but doesn't throw
}
error(message: string): void {
// No-op, but doesn't throw
}
}
// Client can use any logger
@Component({...})
export class MyComponent {
constructor(private logger: Logger) {}
doSomething() {
this.logger.log('Action performed'); // Works with any implementation
}
}
❌ Bad example (violates behavioral contract):
interface Cache<T> {
get(key: string): T | undefined;
set(key: string, value: T): void;
}
class InMemoryCache<T> implements Cache<T> {
private store = new Map<string, T>();
get(key: string): T | undefined {
return this.store.get(key);
}
set(key: string, value: T): void {
this.store.set(key, value);
}
}
class ReadOnlyCache<T> implements Cache<T> {
constructor(private data: Map<string, T>) {}
get(key: string): T | undefined {
return this.data.get(key);
}
set(key: string, value: T): void {
throw new Error('Cannot modify read-only cache'); // ❌ Violates LSP!
}
}
// Client expects all Cache implementations to support set
function cacheUser(cache: Cache<User>, user: User) {
cache.set(user.id, user); // Breaks with ReadOnlyCache!
}
✅ Good example (separate read/write concerns):
interface ReadableCache<T> {
get(key: string): T | undefined;
}
interface WritableCache<T> extends ReadableCache<T> {
set(key: string, value: T): void;
}
class InMemoryCache<T> implements WritableCache<T> {
private store = new Map<string, T>();
get(key: string): T | undefined {
return this.store.get(key);
}
set(key: string, value: T): void {
this.store.set(key, value);
}
}
class ReadOnlyCache<T> implements ReadableCache<T> {
constructor(private data: Map<string, T>) {}
get(key: string): T | undefined {
return this.data.get(key);
}
}
// Clients use appropriate interface
function readUser(cache: ReadableCache<User>, id: string): User | undefined {
return cache.get(id); // Works with both
}
function cacheUser(cache: WritableCache<User>, user: User): void {
cache.set(user.id, user); // Only accepts writable caches
}
💡 In frontend applications:
- React hooks: Custom hooks implementing the same interface should behave consistently.
- Services/APIs: All implementations of an API client should honor the same contract (return types, error handling).
- Components: Components accepting props/interfaces should work correctly with all valid implementations.
- Validation: All validators for a field type should follow the same contract (return ValidationError | null).
- State management: Different store implementations should maintain the same behavioral guarantees.
Questions & Answers
- Implementations that throw errors for base interface methods
- Type guards checking specific implementations before calling methods
- Components that check
instanceofor type properties before using props - Tests that fail when swapping one implementation for another
A: Watch for:
A: Components accepting interfaces/types must work correctly with all valid props. Don't add runtime checks like if (props.type === 'special') that break substitutability. Use separate component types or composition instead.
- Require stricter input (e.g., non-null when base accepts null)
- Provide weaker output (e.g., returning
undefinedwhen base guarantees a value) - Throw different exceptions than documented in the base contract
A: Derived types shouldn't:
A: When implementations need to disable base behavior or add flags to skip inherited logic. Use composition with focused interfaces instead:
// Bad: inheritance with disabled methods
class BaseForm extends Component {
submit() { /* ... */ }
reset() { /* ... */ }
}
class ReadOnlyForm extends BaseForm {
submit() { throw new Error('Read-only'); } // ❌
}
// Good: composition
interface Form {
render(): ReactNode;
}
interface SubmittableForm extends Form {
submit(): void;
}A: Create contract tests that run against the base interface:
describe('Storage contract', () => {
const implementations = [
new LocalStorage(),
new SessionStorage(),
new MemoryStorage()
];
implementations.forEach(storage => {
it(`${storage.constructor.name} honors contract`, () => {
storage.set('key', 'value');
expect(storage.get('key')).toBe('value');
});
});
});A: All components accepting the same prop interface should work identically. Don't create components that require specific prop combinations that violate the interface contract:
// Bad
interface ButtonProps {
onClick?: () => void;
disabled?: boolean;
}
// This component violates LSP if it requires onClick when not disabled
function Button({ onClick, disabled }: ButtonProps) {
if (!disabled && !onClick) {
throw new Error('onClick required'); // ❌
}
}
// Good - make requirements explicit in the type
interface ButtonProps {
onClick: () => void;
disabled?: boolean;
}A: Implementations should not introduce unexpected errors. If the base promises to handle a case, implementations shouldn't throw:
// Base contract: always returns a value
interface Parser {
parse(input: string): ParsedData;
}
// Bad: violates LSP by throwing
class StrictParser implements Parser {
parse(input: string): ParsedData {
if (!input) throw new Error('Empty input'); // ❌
}
}
// Good: honors contract
class StrictParser implements Parser {
parse(input: string): ParsedData {
if (!input) return { valid: false, data: null };
// Parse and return
}
}A: Component code checking specific implementation types:
// Bad - LSP violation
function DataList({ source }: { source: DataSource }) {
if (source instanceof ApiDataSource) {
// Special handling for API source ❌
} else if (source instanceof CachedDataSource) {
// Different handling for cached source ❌
}
}
// Good - polymorphic behavior
function DataList({ source }: { source: DataSource }) {
const data = source.fetch(); // Works for all implementations ✅
}A: Custom hooks with the same interface should be substitutable:
// Both hooks must honor the same contract
function useLocalState(key: string): [string, (value: string) => void];
function useRemoteState(key: string): [string, (value: string) => void];
// Component works with either
function MyComponent() {
const [value, setValue] = useLocalState('key'); // or useRemoteState
// Usage is identical
}A: All implementations of an injected abstract class or interface token must be substitutable. Use dependency injection to swap implementations without breaking consumers:
// Abstract token
export abstract class ApiClient {
abstract get<T>(url: string): Observable<T>;
}
// Implementations must honor contract
@Injectable()
export class HttpApiClient extends ApiClient {
get<T>(url: string): Observable<T> {
return this.http.get<T>(url);
}
}
@Injectable()
export class MockApiClient extends ApiClient {
get<T>(url: string): Observable<T> {
return of(mockData as T);
}
}
// Component works with any implementation
constructor(private api: ApiClient) {}