This piece began with a small object-oriented design question a friend raised on WeChat: how do you avoid instanceof without losing flexibility? I have kept that starting point because it leads naturally into interfaces, generics, sealed hierarchies, structural typing, and algebraic data types — and because the more personal ending is part of why I still keep this article around.
The scene was concrete. A friend was working on a FIT2099 object-oriented design assignment and needed a function that behaved differently depending on an object’s type. Their tutor had pushed back: under the course’s standards, instanceof and type casting were not allowed, and leaning on them signalled a failure to use polymorphism, composition, and the type system — enough for heavy deductions. The frustration was reasonable, because the thing they were branching on was already an interface, itself a layer of abstraction rather than a concrete class.
My answer was that this is a design question worth unpacking rather than a rule to obey. In everyday engineering a temporary instanceof branch can be tolerable when the feature is tiny and the team knows it will be refactored. But as the number of variants grows, that branch quietly becomes incomplete: add a new type and nothing reminds you to update it. The safer direction is to move the distinction into a structure the compiler can check — polymorphism, a type parameter, or a sealed hierarchy. Concretely, a few options fit: let generics dispatch behaviour through type parameters; use Java 17 sealed classes to declare a closed hierarchy whose completeness the compiler can verify; or, more conservatively, agree on a shared enum exposed through a method so some logic can branch without instanceof; and where it fits, reach for design patterns like Strategy, Observer, or Visitor to replace hardcoded type checks with object-oriented abstraction.
This conversation reveals a deep programming language design problem: How can we maintain code flexibility while letting the compiler help us discover errors?
The essence of the instanceof problem is actually a trade-off between runtime type checking and compile-time type safety. When we write code like this:
if (obj instanceof String) { // Handle strings} else if (obj instanceof Integer) { // Handle integers} else if (obj instanceof Double) { // Handle doubles}We’re essentially telling the compiler: “Trust me, I’ll handle all possible cases.” But if a new type is added, the compiler can’t remind us to update this logic. This is the risk brought by incompleteness[+incompleteness].
This problem can be more precisely framed within the Expression Problem. The Expression Problem describes the challenge in programming languages of how to easily extend both new data types and new operations:
- Object-oriented nominal subtyping: Easy to extend with new types, but unfriendly to extending with new operations
- Algebraic data types (sum types): The opposite—easy to extend with new operations, but less friendly to adding new types
When we use instanceof chained branches, we’re essentially extending along the operation dimension, but this extension cannot trigger compiler reminders when new type variants are added—this is a typical manifestation of the “new type” difficulty in the Expression Problem.
[+incompleteness]:
Completeness is a concept from mathematics referring to a system’s ability to cover all possible cases. In programming languages, completeness means the compiler can check whether all possible type branches have been handled. If we use instanceof, the compiler cannot guarantee we’ve handled all types, which can lead to runtime errors. Note not to confuse completeness with exhaustiveness, the latter usually referring to all possible patterns being covered in pattern matching, which is a more specific concept.
I want to keep the question concrete: when should a design move from runtime type checks to a type-level structure that the compiler can inspect?
That question appears in many forms. Java interfaces push behavior into objects. C++ templates and concepts move constraints into compilation. Kotlin sealed hierarchies and TypeScript discriminated unions make variants visible to the checker. Haskell algebraic data types make the data shape itself explicit. The syntax changes, but the underlying pressure is the same: how do we describe the shape and behavior of data so that tools can help humans build reliable systems?
The Essential Motivation of Abstraction: From Concrete to General
The starting question is plain: Why do we need abstraction?
The practical reason is complexity. A problem often has several possible implementations, and abstraction lets us separate the contract from the implementation:
- Hide implementation details - Users only need to care about “what can be done,” not “how to do it”
- Unify operation interfaces - Different implementations can be accessed through the same interface
- Facilitate replacement and extension - Implementations can be changed without affecting users
Returning to the original instanceof problem, the goal is to convert runtime type judgments into compile-time type guarantees. A small shape example makes the trade-off visible.
Shape Processing Abstraction
Suppose we need to handle area calculations for different shapes. Without abstraction, we might write:
def calculate_area(shape): if isinstance(shape, Circle): return 3.14159 * shape.radius ** 2 elif isinstance(shape, Rectangle): return shape.width * shape.height elif isinstance(shape, Triangle): return 0.5 * shape.base * shape.height # If a Square type is added later, it's easily missed here!The problems with this code are obvious:
- Every time a new shape is added, this function must be modified
- The compiler cannot check if all cases are handled
- It violates the “Open-Closed Principle (OCP)” (open for extension, closed for modification)
Abstract Solutions
Through abstraction, we can convert this runtime judgment into compile-time guarantees:
from abc import ABC, abstractmethod
class Shape(ABC): @abstractmethod def area(self) -> float: pass
class Circle(Shape): def __init__(self, radius: float): self.radius = radius
def area(self) -> float: return 3.14159 * self.radius ** 2
class Rectangle(Shape): def __init__(self, width: float, height: float): self.width = width self.height = height
def area(self) -> float: return self.width * self.height
# Now can handle all shapes uniformly, no instanceof neededdef calculate_total_area(shapes: list[Shape]) -> float: return sum(shape.area() for shape in shapes)The Value of Abstraction
This small example shows why the move helps:
- Compile-time guarantees - Every shape must implement the
area()method, checked by the compiler - Extension-friendly - Adding new shapes only requires adding new classes, no need to modify existing code
- Runtime safety - No longer worry about missing certain cases
- Code clarity - Each class has clear responsibilities, following the Single Responsibility Principle
The shift is from runtime judgments like “if it’s X, do Y” to compile-time contracts like “X knows how to do Y”. That reduces one class of missed branches and makes responsibilities easier to see.
The rest of the note compares how several languages make that move: Java interfaces, C++ templates and concepts, Kotlin sealed classes, TypeScript discriminated unions, and Haskell ADTs.
Why Care About Type Abstraction
Before diving into specific languages, let’s consider the two goals that any data abstraction pursues:
- Encapsulation of invariants — Let the compiler help us prevent invalid states from occurring
- Composability — Let different modules and teams exchange data without binding to specific implementations
Although syntax varies by language paradigm, the underlying motivations remain consistent. Next, we’ll compare how these goals manifest in Java, C++, Kotlin, TypeScript, and Haskell.
Java’s Interfaces and Generics: Behavior-Centric
Java gives a useful first version of the solution. Its interfaces and generics move part of the instanceof problem into behavioral contracts and parameterized types.
Since Java’s class inheritance and abstract classes are similar to the Python example above, we won’t provide specific examples here. We’ll start directly with Java’s interfaces and generics.
Java early on mainly used interfaces to implement abstraction. Interfaces describe a set of behavioral contracts, allowing classes to promise they implement certain methods. This is helpful for dependency inversion and modular design, but pays relatively less attention to the specific implementation of data.
The Essence of Interfaces: Behavioral Polymorphism
In Java, interfaces are more than method lists; they express a form of behavioral polymorphism. Let’s look at a more complete example:
// Define a payment processor interfaceinterface PaymentProcessor { boolean processPayment(Payment payment); void refundPayment(String transactionId); PaymentStatus getPaymentStatus(String transactionId);}
// Different implementation methodsclass CreditCardProcessor implements PaymentProcessor { public boolean processPayment(Payment payment) { // Credit card payment logic return validateCard(payment) && chargeCard(payment); }
public void refundPayment(String transactionId) { // Credit card refund logic refundToCard(transactionId); }
public PaymentStatus getPaymentStatus(String transactionId) { // Query credit card payment status return queryCardStatus(transactionId); }}
class PayPalProcessor implements PaymentProcessor { public boolean processPayment(Payment payment) { // PayPal payment logic return authenticateWithPayPal(payment) && executePayment(payment); }
public void refundPayment(String transactionId) { // PayPal refund logic refundViaPayPal(transactionId); }
public PaymentStatus getPaymentStatus(String transactionId) { // Query PayPal payment status return queryPayPalStatus(transactionId); }}
// Code using the interface, not caring about specific implementationclass PaymentService { private PaymentProcessor processor;
public PaymentService(PaymentProcessor processor) { this.processor = processor; // Dependency injection }
public boolean handlePayment(Payment payment) { return processor.processPayment(payment); }}This example demonstrates several important characteristics of interfaces:
- Behavioral unity: All payment processors have the same method signatures
- Implementation diversity: Different payment methods have different internal implementations
- Decoupling: PaymentService only depends on the interface, not specific implementations
- Easy testing: PaymentProcessor can be mocked to test PaymentService
interface Renderer { void render(Document doc);}
class HtmlRenderer implements Renderer { public void render(Document doc) { /* ... */ }}Interfaces make it convenient for us to replace different implementations, but don’t directly explain what Document actually contains. As systems grew larger, Java 5 introduced generics to provide compile-time type parameters:
Generics can be simply described as types of types, allowing us to parameterize different types onto the same interface:
interface Repository<T> { void save(T entity); Optional<T> findById(UUID id);}In this code, Repository<T> abstracts some entity type T, and directly tells the compiler that during checking, the save method can only accept parameters of type T, and the Optional returned by findById can only contain values of type T. This lets us write more generic code without needing to write a new interface for each entity type.
Generics bring stronger static type guarantees, with the compiler able to confirm that repositories only handle consistent entity types. But Java generics have the characteristics of nominal typing and type erasure:
- Type erasure is the essence of Java generics, affecting runtime reification and certain specialization optimizations
- For reference type parameters, there’s typically no automatic overhead
- For primitive type parameters, since they cannot be type arguments, boxing/unboxing and potential escape analysis costs are introduced
- Meanwhile, JIT inlining can sometimes eliminate virtual call overhead
Nevertheless, the combination of interfaces and generics remains an important step in decoupling behavior from concrete classes while preserving a single-inheritance object model.
C++ Templates: Code Generation as Abstraction
If Java chose to implement polymorphism through interfaces at runtime, C++ chose a completely different path — moving type checking work to compile time. This approach directly challenges our traditional understanding of “abstraction,” providing a completely new perspective for solving the instanceof problem.
C++ templates are a compile-time metaprogramming mechanism that treats types as compile-time values and generates new code for each instantiation.
Function Templates: Compile-time Polymorphism
Let’s start with a simple function template:
template <typename T>T clamp(T value, T min, T max) { if (value < min) return min; if (value > max) return max; return value;}
// Usage examplesint x = clamp(42, 0, 100); // T = intdouble y = clamp(3.14, 0.0, 1.0); // T = doubleThis example shows the basic usage of templates, but the true power of C++ templates lies in compile-time computation and type specialization:
// Compile-time computation: Template metaprogrammingtemplate <int N>struct Factorial { static constexpr int value = N * Factorial<N - 1>::value;};
template <>struct Factorial<0> { static constexpr int value = 1;};
// Usage: Factorial<5>::value is computed as 120 at compile timeClass Templates: Type Generators
C++ class templates can serve as type generators, which is quite different from Java’s generic classes:
template <typename T>class Vector {private: T* data; size_t size; size_t capacity;
public: Vector() : data(nullptr), size(0), capacity(0) {}
void push_back(const T& value) { if (size >= capacity) { resize(capacity == 0 ? 1 : capacity * 2); } data[size++] = value; }
T& operator[](size_t index) { return data[index]; } const T& operator[](size_t index) const { return data[index]; }
size_t getSize() const { return size; }
private: void resize(size_t new_capacity) { T* new_data = new T[new_capacity]; for (size_t i = 0; i < size; ++i) { new_data[i] = data[i]; } delete[] data; data = new_data; capacity = new_capacity; }};
// Different T generates completely different classesVector<int> intVector; // Generates Vector<int>Vector<std::string> strVector; // Generates Vector<std::string>Template Specialization: Conditional Behavior
C++ templates support specialization, allowing us to provide special implementations for specific types:
template <typename T>class StringConverter {public: static std::string toString(const T& value) { return std::to_string(value); }};
// Specialization for std::stringtemplate <>class StringConverter<std::string> {public: static std::string toString(const std::string& value) { return value; }};
// Specialization for pointerstemplate <typename T>class StringConverter<T*> {public: static std::string toString(T* ptr) { return ptr ? "0x" + std::to_string(reinterpret_cast<uintptr_t>(ptr)) : "nullptr"; }};Modern C++: Concepts
C++20 introduced Concepts, making template constraints clearer and more concise:
template <typename T>concept Numeric = std::is_integral_v<T> || std::is_floating_point_v<T>;
template <Numeric T>T add(T a, T b) { return a + b;}
// Usageadd(3, 4); // Correct: int is Numericadd(3.5, 2.1); // Correct: double is Numericadd("hello", "world"); // Error: string is not NumericFundamental Differences from Java Generics
C++ templates and Java generics have essential differences:
- Compile-time vs Runtime: C++ templates generate code at compile time, Java generics erase types at runtime
- Type preservation: C++ preserves complete type information, Java erases type parameters
- Performance: C++ templates pursue “zero-overhead abstraction”, but need to be aware of code bloat and compilation time, error message complexity costs. This aligns with longer compilation times and more complex error messages, requiring balance between “goals and costs”.
- Expressiveness: C++ templates support compile-time computation and metaprogramming, Java generics mainly for type safety
C++ templates are expressive, but they come with longer compilation times and more complex error messages. They move abstraction and optimization into compile time in a way Java generics do not.
The theme emphasized by templates will keep returning later: abstraction is also about operating on families of related types.
Java Interfaces vs C++ Concepts: Two Different Abstraction Philosophies
Java’s interfaces and C++‘s abstraction mechanisms represent two different design philosophies. Let’s understand their differences through concrete examples.
Java Interfaces: Explicit Behavioral Contracts
Java’s interfaces are a form of explicit behavioral contracts, where all implementations must explicitly declare:
// Java's Comparator interfacepublic interface Comparator<T> { int compare(T o1, T o2); boolean equals(Object obj);
// Default methods (Java 8+) default Comparator<T> reversed() { return Collections.reverseOrder(this); }
default Comparator<T> thenComparing(Comparator<? super T> other) { return (c1, c2) -> { int res = compare(c1, c2); return (res != 0) ? res : other.compare(c1, c2); }; }}
// Concrete implementationclass StudentComparator implements Comparator<Student> { @Override public int compare(Student s1, Student s2) { return Integer.compare(s1.getGrade(), s2.getGrade()); }}
// UsageList<Student> students = ...;students.sort(new StudentComparator());C++ Concepts: Abstraction Based on Compile-time Constraints
C++ doesn’t have a true interface concept, but can achieve similar functionality through abstract base classes and Concepts:
// C++ traditional approach: Abstract base classtemplate <typename T>class Comparator {public: virtual ~Comparator() = default; virtual int compare(const T& a, const T& b) const = 0;};
class StudentComparator : public Comparator<Student> {public: int compare(const Student& a, const Student& b) const override { return a.getGrade() < b.getGrade() ? -1 : a.getGrade() > b.getGrade() ? 1 : 0; }};
// Modern C++: Concepts (C++20)template <typename T>concept Comparable = requires(const T& a, const T& b) { { a < b } -> std::convertible_to<bool>; { a > b } -> std::convertible_to<bool>; { a == b } -> std::convertible_to<bool>;};
// Sorting function based on Conceptstemplate <typename T, typename Comp>requires requires(const Comp& comp, const T& a, const T& b) { { comp(a, b) } -> std::convertible_to<int>;}void sort(T begin, T end, Comp comparator) { // Implement sorting logic}Practical Comparison: Equality Concept
Let’s compare the two languages through a more complex example—the Equality concept:
Java’s Equality Abstraction
// Java functional interface@FunctionalInterfacepublic interface EqualityChecker<T> { boolean areEqual(T a, T b);
// Composition operations default EqualityChecker<T> and(EqualityChecker<? super T> other) { return (a, b) -> areEqual(a, b) && other.areEqual(a, b); }
default EqualityChecker<T> or(EqualityChecker<? super T> other) { return (a, b) -> areEqual(a, b) || other.areEqual(a, b); }
default EqualityChecker<T> negate() { return (a, b) -> !areEqual(a, b); }}
// Usage exampleclass Person { private String name; private int age;
// Static factory methods public static EqualityChecker<Person> byName() { return (p1, p2) -> p1.name.equals(p2.name); }
public static EqualityChecker<Person> byAge() { return (p1, p2) -> p1.age == p2.age; }
public static EqualityChecker<Person> byNameAndAge() { return byName().and(byAge()); }}C++‘s Equality Abstraction
// C++ traditional approach: Function objectstemplate <typename T>struct EqualityChecker { virtual bool operator()(const T& a, const T& b) const = 0; virtual ~EqualityChecker() = default;};
// Concrete implementationstruct PersonNameEquality : EqualityChecker<Person> { bool operator()(const Person& a, const Person& b) const override { return a.getName() == b.getName(); }};
// Modern C++: Lambda and Conceptstemplate <typename T>concept EqualityCheckable = requires(const T& a, const T& b) { { a == b } -> std::convertible_to<bool>;};
// Implementation of composition operationstemplate <typename T, typename F1, typename F2>class AndEquality : public EqualityChecker<T> { F1 f1; F2 f2;public: AndEquality(F1 f1, F2 f2) : f1(f1), f2(f2) {}
bool operator()(const T& a, const T& b) const override { return f1(a, b) && f2(a, b); }};
// Factory functionstemplate <typename T, typename F1, typename F2>auto make_and_equality(F1 f1, F2 f2) { return AndEquality<T, F1, F2>(f1, f2);}
// Modern approach using lambdasauto personByNameEquality = [](const Person& a, const Person& b) { return a.getName() == b.getName();};
auto personByAgeEquality = [](const Person& a, const Person& b) { return a.getAge() == b.getAge();};
auto personByNameAndAge = make_and_equality<Person>( personByNameEquality, personByAgeEquality);Core Differences Comparison
-
Type System Differences:
- Java: Runtime type erasure, polymorphism based on inheritance
- C++: Compile-time type preservation, polymorphism based on templates
-
Memory and Performance:
- Java: Virtual function calls, runtime overhead
- C++: Template instantiation, compile-time optimization, zero runtime overhead
-
Flexibility:
- Java: Single inheritance of interfaces, but supports default methods
- C++: Multiple inheritance, template specialization, Concepts constraints
-
Error Handling:
- Java: Compile-time checks + runtime exceptions
- C++: Mainly compile-time errors (complex template error messages)
-
Learning Curve:
- Java: Simple and intuitive, easy to get started
- C++: Complex concepts, need deep understanding of template metaprogramming
Practical Application Recommendations
Choose Java interfaces when:
- Need runtime polymorphism
- Team skill levels vary
- Need simple and explicit contracts
- Dependency injection and framework integration
Choose C++ abstraction when:
- Performance requirements are extremely high
- Need compile-time optimization
- Complex type operations and metaprogramming
- Need zero-cost abstraction
These two different abstraction philosophies each have their pros and cons, and the choice depends on specific application scenarios and team needs.
Kotlin and Modern Java’s Sealed Hierarchies: Constraining Extension
As object-oriented languages matured, developers wanted compilers to understand when a type hierarchy was “complete.” This led to Kotlin’s sealed classes and Java 17+‘s sealed interfaces. Sealed hierarchies declare that only a fixed set of subclasses can implement the contract, usually within the same compilation unit:
Kotlin
import java.math.BigDecimal
sealed interface PaymentCommand { val amount: BigDecimal}
data class Charge( override val amount: BigDecimal, val cardToken: String) : PaymentCommand
object RefundAll : PaymentCommand { override val amount = BigDecimal.ZERO}
fun PaymentCommand.describe(): String = when (this) { is Charge -> "Charge ${amount} to card ${cardToken}" RefundAll -> "Refund all remaining balance"}
// Usage examplefun processCommands(commands: List<PaymentCommand>) { commands.forEach { command -> println(command.describe()) }}Java
import java.math.BigDecimal;
public sealed interface PaymentCommand { BigDecimal amount();
// Processing method default String describe() { return switch (this) { case Charge charge -> "Charge " + charge.amount() + " to card " + charge.cardToken(); case RefundAll refundAll -> "Refund all remaining balance"; }; }}
// Implementation classes must be declared nested in the same filepublic record Charge(BigDecimal amount, String cardToken) implements PaymentCommand {}
public final class RefundAll implements PaymentCommand { private static final RefundAll INSTANCE = new RefundAll();
private RefundAll() {}
public static RefundAll getInstance() { return INSTANCE; }
@Override public BigDecimal amount() { return BigDecimal.ZERO; }}
// Usage examplepublic class PaymentProcessor { public void processCommands(List<PaymentCommand> commands) { commands.forEach(command -> { System.out.println(command.describe()); }); }}Through sealing, Kotlin (and modern Java) can provide exhaustive when/switch checking. Since the compiler knows all subtypes, no else branch is needed. The sealing mechanism therefore strikes a balance between open interface design and the closed-world guarantees we’ll see later in algebraic data types.
TypeScript: A Self-Contained Structural Type System
TypeScript was born from JavaScript’s dynamic ecosystem. It embraces structural typing, where compatibility depends on the shape of objects rather than their declared names. We can see its abstraction style through a few practical examples.
Structural Typing and Interfaces
TypeScript’s structural type system makes “duck typing”[+duck_typing] type-safe: [+duck_typing]: Duck typing is a concept from dynamic type systems where an object’s suitability depends on whether it has certain methods and properties, not its concrete type. In TypeScript, the structural type system allows us to perform type checking based on the shape of objects rather than relying on explicit type declarations. This makes code more flexible and composable.
// Define shapes, not caring about concrete typesinterface Point2D { x: number; y: number;}
interface Point3D { x: number; y: number; z: number;}
// Any object with x and y properties will workfunction distance(p1: Point2D, p2: Point2D): number { return Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);}
// Structural typing makes Point3D automatically compatible with Point2Dconst point3d: Point3D = { x: 1, y: 2, z: 3 };const point2d: Point2D = { x: 4, y: 5 };
console.log(distance(point3d, point2d)); // Completely legal!Discriminated Unions: TypeScript’s ADTs
TypeScript’s discriminated union types provide capabilities similar to algebraic data types:
// Discriminated union for payment commandstype PaymentCommand = | { kind: "charge"; amount: number; cardToken: string } | { kind: "refund"; transactionId: string; amount: number } | { kind: "query"; transactionId: string };
// Type-safe handling with exhaustiveness checkingfunction processPayment(command: PaymentCommand): string { switch (command.kind) { case "charge": return `Charging $${command.amount} to card ${command.cardToken}`; case "refund": return `Refunding $${command.amount} for transaction ${command.transactionId}`; case "query": return `Querying status for transaction ${command.transactionId}`; default: // TypeScript ensures this can never be reached const _exhaustiveCheck: never = command; return _exhaustiveCheck; }}
// Helper function for exhaustiveness checkingfunction assertNever(value: never): never { throw new Error(`Unexpected object: ${value}`);}
// Usage in more complex scenariosfunction handlePaymentCommand(command: PaymentCommand): string { switch (command.kind) { case "charge": return `Charging $${command.amount} to card ${command.cardToken}`; case "refund": return `Refunding $${command.amount} for transaction ${command.transactionId}`; case "query": return `Querying status for transaction ${command.transactionId}`; default: // Use assertNever for better error messages return assertNever(command); }Mapped Types and Conditional Types
TypeScript’s type manipulation capabilities make it a true “type calculator”:
// Mapped types: Create new types based on existing onestype Optional<T> = { [P in keyof T]?: T[P];};
type ReadOnly<T> = { readonly [P in keyof T]: T[P];};
interface User { id: number; name: string; email: string;}
type OptionalUser = Optional<User>; // All properties become optionaltype ReadOnlyUser = ReadOnly<User>; // All properties become readonlyControl Flow Analysis and Type Narrowing
TypeScript’s intelligent type narrowing makes runtime checks safer:
// Type guard functionsfunction isString(value: unknown): value is string { return typeof value === "string";}
function processValue(value: unknown) { if (isString(value)) { // TypeScript knows this is string type console.log(value.toUpperCase()); }
// typeof narrowing if (typeof value === "number") { console.log(value.toFixed(2)); }
// Instance narrowing if (value instanceof Date) { console.log(value.toISOString()); }}
// Literal type narrowingtype Status = "pending" | "processing" | "completed" | "failed";
function updateStatus(status: Status) { if (status === "completed") { // Only "completed" can enter this branch console.log("Task completed!"); }}Generics and Constraints
TypeScript’s generic system supports several abstraction patterns:
// Generics with constraintsinterface Identifiable { id: string;}
function findById<T extends Identifiable>( items: T[], id: string): T | undefined { return items.find(item => item.id === id);}
// Generic defaultsinterface Repository<T = any> { findById(id: string): Promise<T>; save(entity: T): Promise<void>; delete(id: string): Promise<boolean>;}
// Keyof genericsfunction getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key];}
const user = { id: "1", name: "Zhang San", age: 25 };const userName = getProperty(user, "name"); // Type is stringconst userAge = getProperty(user, "age"); // Type is numberThanks to the language server-integrated type system, TypeScript builds a self-contained development experience: the compiler, toolchain, and ecosystem conventions reinforce consistent data modeling even in codebases that mix JavaScript, server-side frameworks, and UI libraries.
Haskell: Algebraic Data Types and Type Classes
The functional tradition pushes data abstraction toward Algebraic Data Types (ADTs) and Type Classes. Haskell is the cleanest place to inspect that style.
Algebraic Data Types: Algebraic Expression of Data
Haskell’s ADTs declaratively combine products (“AND”) and sums (“OR”):
-- Define basic typestype Money = Doubletype Text = String
-- Payment commands: Sum type (OR relationship)data PaymentCommand = Charge { amount :: Money, cardToken :: Text } | Refund { transactionId :: Text, amount :: Money } | Query { transactionId :: Text } | RefundAll deriving (Show, Eq)
-- User: Product type (AND relationship)data User = User { userId :: Int , userName :: Text , userEmail :: Text , userAge :: Int , isActive :: Bool } deriving (Show, Eq)
-- Recursive data types: Binary treedata BinaryTree a = Leaf | Node (BinaryTree a) a (BinaryTree a) deriving (Show, Eq, Functor)Pattern Matching: Exhaustiveness Guarantees
Haskell’s pattern matching requires exhaustiveness by default, letting the compiler help you check all possible cases:
-- Handle payment commands, compiler ensures all cases are handledprocessPayment :: PaymentCommand -> TextprocessPayment (Charge amount cardToken) = "Charging " ++ show amount ++ " to card " ++ cardTokenprocessPayment (Refund transactionId amount) = "Refunding " ++ show amount ++ " for transaction " ++ transactionIdprocessPayment (Query transactionId) = "Querying status for transaction " ++ transactionIdprocessPayment RefundAll = "Refund all remaining balance"
-- Use pattern matching to handle recursive data typestreeSum :: Num a => BinaryTree a -> atreeSum Leaf = 0treeSum (Node left value right) = treeSum left + value + treeSum right
-- Complex pattern matchingvalidateCommand :: PaymentCommand -> Either Text PaymentCommandvalidateCommand cmd@(Charge amount _) | amount <= 0 = Left "Charge amount must be positive" | otherwise = Right cmdvalidateCommand cmd@(Refund _ amount) | amount < 0 = Left "Refund amount cannot be negative" | otherwise = Right cmdvalidateCommand cmd = Right cmdType Classes: Abstract Interfaces for Behavior
Haskell’s type classes capture behavior that applies to multiple types while keeping implementations independent:
-- Basic type class: Comparableclass Comparable a where compare :: a -> a -> Ordering (<), (>), (<=), (>=) :: a -> a -> Bool
-- Default implementations x < y = compare x y == LT x > y = compare x y == GT x <= y = compare x y /= GT x >= y = compare x y /= LT
-- Instantiate for Intinstance Comparable Int where compare x y | x == y = EQ | x < y = LT | otherwise = GT
-- Semigroup: Associative operationsclass Semigroup a where (<>) :: a -> a -> a
-- Monoid: Semigroup with identity elementclass Semigroup a => Monoid a where mempty :: a mappend :: a -> a -> a mappend = (<>)
-- List monoid instanceinstance Semigroup [a] where (<>) = (++)
instance Monoid [a] where mempty = []
-- Multiplicative monoid for numbersnewtype Product a = Product { getProduct :: a } deriving (Show, Eq)
instance Num a => Semigroup (Product a) where (Product x) <> (Product y) = Product (x * y)
instance Num a => Monoid (Product a) where mempty = Product 1Practical Application: Simple Validation Example
-- Simple validation type classclass Validatable a where validate :: a -> Either Text a
-- Add validation for PaymentCommandinstance Validatable PaymentCommand where validate (Charge amount _) | amount <= 0 = Left "Charge amount must be positive" | otherwise = Right (Charge amount _) validate (Refund _ amount) | amount < 0 = Left "Refund amount cannot be negative" | otherwise = Right (Refund _ amount) validate cmd = Right cmd
-- Use validationprocessValidatedCommand :: PaymentCommand -> Either Text TextprocessValidatedCommand cmd = do validatedCmd <- validate cmd return $ processPayment validatedCmdHaskell’s type classes implement ad-hoc polymorphism while preserving type inference and efficient compilation. With the support of higher-kinded types, they can express complex abstractions like Applicative, Lens, etc., which are often verbose or unsafe in languages with weaker type systems. ADTs make illegal states unrepresentable from the construction level. This “make illegal states unrepresentable” design philosophy is the core advantage of Haskell’s type system.
Deep Comparison: Java Interfaces vs Haskell Algebraic Data Types
To make the contrast concrete, let’s compare Java’s interface approach with Haskell’s ADT approach through the same example.
Java’s Comparator Interface: Runtime Polymorphism
Java’s Comparator interface is a typical example of runtime polymorphism:
// Java Comparator: Requires explicit interface implementationpublic interface Comparator<T> { int compare(T a, T b);}
// Concrete implementation classesclass StudentGradeComparator implements Comparator<Student> { @Override public int compare(Student a, Student b) { return Integer.compare(a.getGrade(), b.getGrade()); }}
class StudentNameComparator implements Comparator<Student> { @Override public int compare(Student a, Student b) { return a.getName().compareTo(b.getName()); }}
// Usage: Runtime dynamic selectionComparator<Student> comparator = getComparatorFromUser();students.sort(comparator);Characteristics of this approach:
- Runtime polymorphism: Specific comparison logic is determined at runtime
- Explicit implementation: Each comparator needs to explicitly implement the Comparator interface
- Open extension: Anyone can create new comparators
- Type erasure: Generic information is lost at runtime
Haskell’s Ord Type Class: Compile-time Polymorphism
Haskell achieves similar functionality through type classes, but behavior is determined at compile time:
-- Haskell Ord type class: Compile-time derivationclass Ord a where compare :: a -> a -> Ordering (<), (<=), (>), (>=) :: a -> a -> Bool
-- Default implementations x < y = compare x y == LT x <= y = compare x y /= GT x > y = compare x y == GT x >= y = compare x y /= LT
-- Instantiate for Student typedata Student = Student { name :: String , grade :: Int } deriving (Show, Eq)
instance Ord Student where compare (Student n1 g1) (Student n2 g2) | g1 /= g2 = compare g1 g2 | otherwise = compare n1 n2
-- Usage: Comparison behavior determined at compile timesortStudents :: [Student] -> [Student]sortStudents = sort -- sort automatically uses Ord instanceCore Differences Comparison
-
Polymorphism Mechanism:
- Java: Runtime dispatch - Dynamic lookup through virtual function table
- Haskell: Compile-time specialization - Generates specialized comparison functions for each type
-
Type Safety:
- Java: Runtime checking - May throw ClassCastException
- Haskell: Compile-time guarantees - Compilation fails if type has no Ord instance
-
Performance Characteristics:
- Java: Virtual function call overhead + Possible boxing/unboxing
- Haskell: Static calls + Near zero-overhead under specialization
-
Expressiveness:
- Java: Interface constraints - Can only define method signatures
- Haskell: Type class constraints - Can have default implementations and associated types
More Complex Example: Equality vs Eq
Let’s look at a more complex example showing the true power of Haskell’s type system:
Java’s Equality Checking
// Java: Need multiple interfaces and implementationspublic interface EqualityChecker<T> { boolean areEqual(T a, T b);}
public interface HashProvider<T> { int hashCode(T obj);}
// Composite interfacepublic interface HashableEquality<T> extends EqualityChecker<T>, HashProvider<T> {}
// Concrete implementationpublic class PersonHashableEquality implements HashableEquality<Person> { @Override public boolean areEqual(Person a, Person b) { return a.getName().equals(b.getName()) && a.getAge() == b.getAge(); }
@Override public int hashCode(Person obj) { return Objects.hash(obj.getName(), obj.getAge()); }}Haskell’s Eq Type Class
-- Haskell: One type class solves all problemsclass Eq a where (==) :: a -> a -> Bool (/=) :: a -> a -> Bool
-- Default implementations x == y = not (x /= y) x /= y = not (x == y)
-- Automatically derive Eq instancedata Person = Person { name :: String , age :: Int } deriving (Show, Eq)
-- Haskell can automatically generate:-- 1. Equality comparison for two Persons-- 2. HashCode implementation based on fields-- 3. Compile-time type checkingCosts and Benefits of Haskell’s Type System
Benefits
-
Compile-time Safety:
-- Compilation fails directly if type has no Eq instancefindDuplicates :: Eq a => [a] -> [a]findDuplicates xs = [x | x <- xs, count x xs > 1]-- Error: No instance for (Eq SomeType)findDuplicates [someType1, someType2] -
Automated Type Deduction:
-- Compiler automatically derives need for Ord constraintsortStudents :: [Student] -> [Student]sortStudents = sort -- sort :: Ord a => [a] -> [a] -
Near Zero-Overhead Under Specialization:
-- With SPECIALISE pragma, compiles to equivalent hand-optimized code{-# SPECIALISE instance Ord Student #-}instance Ord Student wherecompare = compareStudents -- Inline optimization -
Illegal States Unrepresentable:
-- Prevent illegal states at compile timedata PaymentStatus = Pending | Processing | Completed-- Cannot create states other than these three
Costs
-
Steep Learning Curve:
- Need to understand type classes, instances, constraints, etc.
- Type deduction mechanism is complex
- Error messages are abstract and hard to understand
-
Long Compilation Times:
- Complex type deduction requires extensive computation
- Compile-time optimizations increase compilation time
-
Reduced Runtime Flexibility:
- Dynamic behavior is limited
- Limited runtime reflection capabilities
-
Ecosystem Limitations:
- Relatively fewer libraries
- Difficult integration with mainstream OO frameworks
Practical Significance
Haskell’s type system demonstrates the ultimate pursuit of programming language design: moving as many errors as possible to compile time. This design philosophy means in practice:
- Reduced testing burden: Compiler has already eliminated entire categories of errors
- Increased refactoring confidence: Type system guarantees safety of modifications
- Documentation as code: Type signatures are the most accurate documentation
- Performance optimization: Compile-time information supports deep optimization
This expressiveness has a cost. Haskell is not suitable for all scenarios, especially projects needing rapid iteration, runtime flexibility, or diverse team skills. Understanding this trade-off is part of choosing the right technology stack.
From the instanceof problem onward, each abstraction approach is answering the same question: How do we preserve flexibility without giving up correctness? Haskell gives a more formal answer, and its value depends on the surrounding needs and constraints.
Cross-Comparison
| Feature / Language | Java Interfaces & Generics | C++ Templates | Kotlin/Java Sealed Types | TypeScript Structural Type System | Haskell ADTs & Type Classes |
|---|---|---|---|---|---|
| Core Abstraction | Behavioral contracts on nominal types | Compile-time code generation over types | Closed hierarchies with exhaustive analysis | Structural typing with unions, inference, and tooling | Algebraic composition of data + behavior |
| Extensibility | Open world; any class can implement | Open world; instantiations everywhere | Closed to declared subclasses | Open; compatibility by shape | Extensible via new data constructors, but limited overall |
| Runtime Representation | Erased generics, single dispatch | Specialized code per instantiation | JVM classes with metadata for sealed hierarchy | Erased at runtime but guided by control-flow narrowing | Extensive compile-time info, erased to efficient core language |
| Safety Guarantees | Behavioral contracts; limited exhaust | Depends on constraints; can be unsafe | Exhaustive when/switch over known variants | Narrowing and unions catch many runtime errors | Exhaustive pattern matching; illegal states impossible |
| Tooling Experience | Mature IDE support | Powerful but complex compilers | Kotlin/Java IDEs enforce sealing rules | Language server explains inferred shapes and discriminated unions | Compiler + REPL ensure laws and instances |
This table shows that no approach dominates universally. Each step in evolution just adds new choices to the developer’s toolbox.
From instanceof to Abstraction: Complete Mindset Shift
Returning to the original problem: how do we avoid using instanceof? The answer is less a trick than a design habit: move uncertain runtime branching into a structure that the language can check.
Problem-Solving Evolution Path
- Identify the problem: Incompleteness and runtime risks brought by
instanceof - Understand the essence: Need to convert runtime type judgments into compile-time type guarantees
- Choose tools: Select appropriate abstraction mechanisms based on language features and scenarios
- Implement solutions:
- Java/Kotlin: Use interfaces, sealed classes, pattern matching
- C++: Utilize templates, Concepts, compile-time polymorphism
- TypeScript: Adopt structural types, discriminated unions, type narrowing
- Haskell: Through ADTs, type classes, pattern matching
Practical Application Recommendations
For Beginners:
- Start with Java interfaces, understand basic concepts of behavioral abstraction
- Gradually explore sealed classes and pattern matching to see what compile-time checking can catch
- Try TypeScript’s structural types, experience the type-safe version of “duck typing”
For Experienced Developers:
- In multi-language projects, understand the mapping relationships between different abstraction styles
- Choose appropriate technology stacks based on performance needs, team skills, ecosystem maturity
- Establish unified abstract thinking, not limited by specific syntax
For System Designers:
- Consider the transmission of type safety at the architectural level
- Use strong type systems to build reliable distributed systems
- Find balance between flexibility and security
Conclusion: At the Intersection of Mathematics, Language, and Computing
While revising this old note, I realized why I still care about the topic. Data type abstraction sits at a useful boundary: programming language design gives the mechanisms, mathematics gives the formal shapes, and linguistics keeps asking what a symbol is supposed to mean.
The Computer Science Perspective: Hierarchy of Abstraction
From a computer science perspective, the abstractions we discussed today are essentially a hierarchical problem. From machine code to assembly, from procedural programming to object-oriented programming, to functional programming, each step wraps lower-level complexity into higher-level abstractions.
Physical layer → Logic gates → Instruction set → Assembly language → High-level languages → Abstract design patternsType systems are one layer in this abstraction stack. They let us catch some mistakes at compile time instead of discovering them after a program crashes in front of a user. That is a practical engineering benefit first. It also points toward a more scientific view of programming: programs can be checked against explicit structures before they are tested after the fact.
The Pure Mathematics Perspective: Power of Formal Systems
As someone drawn to mathematics, I keep noticing the resemblance between type systems and formal systems. Haskell’s type classes feel close to algebraic structures such as groups, rings, and fields. The “sum” and “product” of algebraic data types echo disjoint unions and Cartesian products. Type inference starts to look like a small proof search problem.
-- This isn't just code, this is mathematics!data PaymentCommand = Charge ... | Refund ... | Query ...-- This is a sum type, corresponding to disjoint union in mathematicsThe Curry-Howard isomorphism gives the slogan: programs are proofs, types are propositions. A type-correct function can be read as a small proof that certain inputs lead to certain outputs. That idea sits behind much of modern programming language design.
The Linguistics Perspective: Precise Expression of Meaning
Linguistics teaches us to focus on precision of expression. The ambiguity of natural language is often the root of errors in programming. When we say “an object,” what exactly are we referring to? Is it a concrete instance, or an abstract concept?
Type systems are like a precise formal language that forces us to think clearly before coding:
- What does this data represent?
- What are its possible values?
- What operations can be performed on it?
This precision training makes me pay more attention to conceptual accuracy and logical rigor when reading and writing.
Cross-Disciplinary Unity: The Essence of Abstraction
In these three fields, I see the common essence of abstraction:
In mathematics, we abstract specific numerical relationships through axioms and definitions, thereby proving universally applicable theorems.
In linguistics, we abstract specific expressions through grammatical rules, thereby constructing meaningful communication.
In computer science, we abstract specific data operations through type systems, thereby building reliable software systems.
They are all answering the same fundamental question: How to express infinite possibilities with finite rules?
Working Rules I Took From This
The practical takeaway is not a grand programming philosophy. It is a small set of habits I want to keep:
1. Use mathematical structure when it clarifies the design: Understanding the mathematical shape of a type system can make abstraction tools easier to choose. If an interface is really encoding an algebraic structure, naming that structure can make the design clearer.
2. Treat programming languages as thinking tools: Choosing a language also means choosing which structures are easy to see and which mistakes are easy to write.
3. Keep abstraction close to the problem: Abstraction is useful only when it helps expose the shape of the problem, not when it hides the problem behind vocabulary.
Thoughts on the Future
At this boundary, two directions feel especially relevant to me:
- Dependent types: Integrating mathematical proofs directly into programming languages, allowing program correctness to be formally verified at compile time
- Natural language processing: Applying type system thinking to natural language understanding and generation, building more precise human-computer interaction
Final Thoughts
The original question was small: how do we avoid instanceof?
After writing through these examples, I think the better question is: what does the program know, and where should that knowledge live? Sometimes it belongs in an interface. Sometimes in a sealed hierarchy, a discriminated union, a type class, or a proof-like type signature.
That is why this topic still interests me. Type abstraction sits where programming language design meets mathematics and linguistics: it asks how finite symbols can make a large space of meanings precise enough for a machine to check.
Discussion