Introduction
Before discussing what typeclasses are, let's paint a scenario that happens in every software engineering company: you have a set of domain types, some of which must be turned into JSON (or protobuf, avro etc...) before returning them as payload of a REST endpoint, or pushing them into a queue. Some of those types are defined by your team, some come from the standard library (like a timestamp), while others may come from a shared company artifact that another team owns, and you are not allowed to touch. How would you provide such functionality?
The naive and poorly scalable answer, would be to use inheritance: declare an interface with a toJson method, and have every type implement it. That works perfectly for types you have control over, until you stumble upon one you don't; at that point it, you can't hack your way around it.
And the reason for this failure, is that the whole premise is wrong: a type, whether custom or from the standard api, should never, ever, have any knowledge about how it will be formatter/serialized. That behavior is something that belongs to whomever is using the type, and it is his/her responsibility to make sure the proper one is provided.
In Scala, this behavior is achieved via a typeclass; it'is how a library like circe provides JSON encoders/decoders for types its authors have never heard of.
As usual, we will see how other languages solves this problem, and then we'll compare the way scala does it.
Python: it works until it doesn't
The default Python answer is duck typing: write the function, call to_json on the values, and hope every value that reaches it actually has one.
from datetime import datetime
class Order:
def __init__(self, id: str, total: int):
self.id = id
self.total = total
def to_json(self) -> str:
return '{"id": "%s", "total": %d}' % (self.id, self.total)
def render_all(values) -> str:
return "[" + ",".join(v.to_json() for v in values) + "]"
render_all([Order("o1", 10)]) # '[{"id": "o1", "total": 10}]'
render_all([datetime.now()]) # AttributeError: 'datetime.datetime' object
# has no attribute 'to_json'The less naive way would be to use Protocol: that moves that failure from runtime to type-check time which, if the project is configured to add (and enable) the type checker, represents a good improvement over duck-typing. The issue is that, as we said before, it's up the developer to be diligent about it, every time.
from typing import Protocol
class Jsonable(Protocol):
def to_json(self) -> str: ...
def render_all(values: list[Jsonable]) -> str:
return "[" + ",".join(v.to_json() for v in values) + "]"
# mypy now says: List item 0 has incompatible type "datetime";
# expected "Jsonable"
render_all([datetime.now()])Notice what the protocol did: it told you that datetime is not compatible. With a bit of digging, you could inspect the Jsonable class and understand that you need a specific method to be implemented. The only solution would be to attach the method to the class at runtime, the process-wide monkey-patching we argued against in the extension methods post and, what's worse, it still wouldn't solve anything, because datetime is implemented in C and rejects the assignment.
There is, however, a third way that you could solve this: by using functools.singledispatch. What it does, is to describe the behaviour into a registry keyed by runtime type, so you can register an implementation for a type you do not own.
from functools import singledispatch
from datetime import datetime
@singledispatch
def to_json(value) -> str:
raise TypeError(f"no encoder for {type(value).__name__}")
@to_json.register
def _(value: int) -> str:
return str(value)
@to_json.register
def _(value: datetime) -> str:
return f'"{value.isoformat()}"'
to_json(datetime.now()) # '"2026-09-05T09:12:44.117"'
to_json({"a": 1}) # TypeError: no encoder for dictThis looks much elegant, but the underlying problem is: when will find out if something went wrong?
That last line should give you a chill down your spine: that would be a production incident, rather than a build failure, or a type-check error. And because the registry is nothing more than a global, shared table for the whole process, nothing prevents a different file to declare a second @to_json.register for int, causing the first one to be silently replaced: the import order decides the winner.
TypeScript: the shape of a typeclass, none of the plumbing
If you look in the internet, you can write code that resembles Scala's typeclasses with TypeScript too. It all boils down to write an interface, parametrized on a generic type A, with an abstract method that transforms that A into something else,; then, you need to declare the type variables that implement such interface:
interface JsonEncoder<A> {
encode(value: A): string;
}
const numberEncoder: JsonEncoder<number> = { encode: (n) => String(n) };
const stringEncoder: JsonEncoder<string> = { encode: (s) => JSON.stringify(s) };
const dateEncoder: JsonEncoder<Date> = { encode: (d) => `"${d.toISOString()}"` };
function renderAll<A>(values: A[], encoder: JsonEncoder<A>): string {
return `[${values.map((v) => encoder.encode(v)).join(",")}]`;
}
renderAll([1, 2, 3], numberEncoder); // "[1,2,3]"
renderAll([new Date()], dateEncoder); // '["2026-09-05T09:12:44.117Z"]'What we get here, is a code that is much clean has better separation of concerns than Python's solutions:
- a clear interface that states what a JSON encoder should provide;
- a battery of concise encoder implementations, one for each type;
- no type is modified at all, so you can apply this pattern to standard and custom types alike;
- you can even provide different serializations for the same type, you just need to name them differently.
But the similarities with Scala stop here: to make the typeclasses work, you must to the plumbing (aka passing the correct encoders) manually, every time they're needed.
And, what's worse, is that the complexity of the typeclasses increases rapidly when you're applying this pattern against types that are more close to the ones you find in a production system, rather than a simple example in a blog post:
interface Order {
id: string;
placedAt: Date;
items: string[];
discount: number | null;
}
function listOf<A>(inner: JsonEncoder<A>): JsonEncoder<A[]> {
return { encode: (xs) => `[${xs.map((x) => inner.encode(x)).join(",")}]` };
}
function nullableOf<A>(inner: JsonEncoder<A>): JsonEncoder<A | null> {
return { encode: (v) => (v === null ? "null" : inner.encode(v)) };
}
const orderEncoder: JsonEncoder<Order> = {
encode: (o) =>
"{" +
[
`"id":${stringEncoder.encode(o.id)}`,
`"placedAt":${dateEncoder.encode(o.placedAt)}`,
`"items":${listOf(stringEncoder).encode(o.items)}`,
`"discount":${nullableOf(numberEncoder).encode(o.discount)}`,
].join(",") +
"}",
};
renderAll(orders, orderEncoder);As you just saw, despite Order contains shapes that already knows, and also has extra encoders for a list of items, and items that might be nullable, the compiler doesn't help you at all. You must do all the manual plumbing by yourself. And if you modify the Order class ad add another extra item, say Currency, you must remember to update its encoder, or you'll never get any hint from the compiler that you missed it.
There is another, more nasty, problem here: you must carry that encoder argument through every layer that sits between the call site, and the encoding. Image you need to publish the Order via Kafka, and you also want to retry the operation once more, in case of error:
function sendBatch<A>(topic: string, values: A[], encoder: JsonEncoder<A>): void {
publish(topic, renderAll(values, encoder));
}
function retryOnceMore<A>(topic: string, values: A[], encoder: JsonEncoder<A>): void {
try {
sendBatch(topic, values, encoder);
} catch {
sendBatch(topic, values, encoder);
}
}
retryOnce("orders", orders, orderEncoder);retryOnceMore has nothing to do with serialisation: all it does is to ensure that publish is called once more, if the call fails the first time. Still, its signature must mention JsonEncoder, because some other call, namely retryOnceMore, needs it.
And the issues don't stop here as well: because the encoder has to be passed around through multiple, layered calls, adn you have different encoders for the same type, it's easy to misuse one for the other, without a way to be warned about the possible mistake:
const cents: JsonEncoder<number> = { encode: (n) => String(n * 100) };
renderAll([1, 2, 3], numberEncoder); // "[1,2,3]"
renderAll([1, 2, 3], cents); // "[100,200,300]", and it compiles just as happilyThe bottom line is not that TypeScript does typeclasses badly: it gives you all you the vocabulary to build them, but then it leaves up to you the resolution mechanism, and provides no way to warn you in case there are multiple typeclasses for the same underlying type, in the same scope.
Java: inherit it, or pass it forever
Java lands in a similar position as Typescript: in some aspects, the syntax looks a bit leaner than TS, but still, despite the strong type system, you still don't have implicit resolution, nor compile-time search of available typeclasses instances. Once more, it's up to the developer to perform the plumbing, manually.
interface JsonEncoder<A> {
String encode(A value);
}
static final JsonEncoder<Instant> INSTANT = v -> "\"" + v + "\"";
static <A> String renderAll(List<A> values, JsonEncoder<A> encoder) {
return values.stream()
.map(encoder::encode)
.collect(Collectors.joining(",", "[", "]"));
}
static <A> JsonEncoder<List<A>> listOf(JsonEncoder<A> inner) {
return xs -> xs.stream().map(inner::encode)
.collect(Collectors.joining(",", "[", "]"));
}
static <A> JsonEncoder<Optional<A>> optionalOf(JsonEncoder<A> inner) {
return o -> o.map(inner::encode).orElse("null");
}
List<Optional<List<Instant>>> payload = fetchPayload();
renderAll(payload, optionalOf(listOf(INSTANT)));Scala: the compiler does the lookup
Scala's answer to the problem is not by adding a new language feature but, instead, by combining the existing features that we discussed in the past posts:
- a trait parametrised on the generic type
A; - concrete instances provided by the
givenkeyword; - an extension method that binds a type
Awith the presence of a typeclass instance.
trait JsonEncoder[A]:
def encode(value: A): String
given JsonEncoder[Int] with
def encode(value: Int): String = value.toString
given JsonEncoder[String] with
def encode(value: String): String = "\"" + value + "\""
extension [A](value: A)(using encoder: JsonEncoder[A])
def toJson: String = encoder.encode(value)
def logPayload[A: JsonEncoder](value: A): Unit = println(value.toJson)
logPayload(42) // 42
logPayload("scalable") // "scalable"Note: the [A: JsonEncoder] in logPayload signature is a context bound. It specifies a constraint on the type A that reads as follows: this function accepts any A, provided the compiler can find a JsonEncoder for it. No inheritance, no wrapper, no argument threaded through the call chain.
Instances for types you do not own
And the same mechanism you just saw, applies to types you do not own as well, such as ones shipped by third-party library, or the standard library:
import java.time.Instant
given JsonEncoder[Instant] with
def encode(value: Instant): String = "\"" + value.toString + "\""
Instant.now().toJson // "2026-09-05T09:12:44.117Z"Nothing about the Instant class changed: there is no patched prototype, no mutated global registry, no subclass. Any code that does not import this file, sees the ordinary Instant and has no idea that you could serialize it to Json.
The part no other language here does
Typeclass instances might require other instances: an encoder for List[A] can exist if, and only if, an encoder for A can be found.
given [A](using inner: JsonEncoder[A]): JsonEncoder[List[A]] with
def encode(values: List[A]): String =
values.map(inner.encode).mkString("[", ",", "]")
given [A](using inner: JsonEncoder[A]): JsonEncoder[Option[A]] with
def encode(value: Option[A]): String =
value.map(inner.encode).getOrElse("null")
val payload: List[Option[List[Instant]]] =
List(Some(List(Instant.now())), None, Some(Nil))
payload.toJson
// [["2026-09-05T09:12:44.117Z"],null,[]]That last call is the whole argument for typeclasses: the compiler needed a JsonEncoder[List[Option[List[Instant]]]], but nobody provided one. However, the compiler was smart enough to understand, from its scope, that it had all the pieces to construct one by itself.
Once you grasp the concept of typeclasses, you will begin to spot its usage throughout the standard library. list.sorted works because the compiler found an Ordering[A], and it sorts a List[(String, Int)] because there is an Ordering typeclass for tuples, built from the orderings of their parts. As long as the typeclasses for the basic types are available (which the standard library does for you), the compiler will happily build more complex typeclasses for you.
What happens when something goes wrong?
What we have said so far is all nice and convenient, but you may ask: what happens if a typeclass instance cannot be found? Well, in this case, the compiler will fail with a build error, stating that it could not find a proper instance.
import java.util.UUID
UUID.randomUUID().toJson
// error:
// No given instance of type JsonEncoder[java.util.UUID] was found
// for parameter encoder of method toJsonFurthermore, if two competing typeclasses for the same type A are also available in the same scope, the compiler will fail with a build error, saying that it cannot decide which one to choose.
given cents: JsonEncoder[Int] with
def encode(value: Int): String = (value * 100).toString
42.toJson
// error: Ambiguous given instances: both given_JsonEncoder_Int and cents
// match type JsonEncoder[Int]Compare this behaviour with Python's registry solution: one raises a runtime error, while the other silently multiplies every integer in your payload by a hundred.
The change an agent is asked to make most
Ask an agent to add that currency field: Scala will fail the build right away, with No given instance of type JsonEncoder[Currency], point out the type and the call site, and the agent will correctly update the code with the proper, missing instance. Python accepts the change and passes the tests, because the fixtures predate the field, then raises TypeError in production. Java and TypeScript accept it too, but quietly drop the field from the payload. Only Scala, out of the four, is capable to give the agent enough feedback to complete its job.
Ambiguity works the same way: an agent that cannot find an existing instance, will try to be helpful and write a fresh one nearby. In Python, that would mean the risking to silently override another instance that is already present in the global registry, while Scala produces a compile error listing both candidates.
And the beauty of all of this, is that your agent doesn't need to learn anything new: typeclasses are just a combination of existing language features, designed to interact with each other elegantly and succinctly.
