Definition
- Serialization – straightforward; the library reads the concrete object's fields regardless of the declared type.
- Polymorphic deserialization – requires a type discriminator property in the JSON so the library can pick the correct permitted subtype to instantiate.
- Jackson – use
@JsonTypeInfo(use = Id.NAME, property = "type")and@JsonSubTypes; modern Jackson can infer subtypes from the sealedpermitslist. - Gson – register a
RuntimeTypeAdapterFactorymapping discriminator values to subtypes; no built-in polymorphic support. - Records – need constructor-based binding (no setters); Jackson handles this via canonical constructors.
- Security – never enable Jackson default typing on untrusted JSON; use controlled
Id.NAMEmapping.
Imagine National Bank of India sends account documents between systems as forms (JSON). Reading a form back is easy if the form has a clear label at the top saying which exact product it is: "Savings," "Current," or "Loan." Without that label, the clerk sees the fields but cannot be sure which product to reconstruct. The type label is the discriminator that tells the reader which permitted product to build.
Understanding the Concepts
Why is serialization of sealed types straightforward?
Serialization (object to JSON) works because the library just reads the concrete object's fields. The sealed hierarchy does not add complexity on the write side.
package com.bank.model;
import com.fasterxml.jackson.annotation.*;
import java.math.BigDecimal;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = SavingsAccount.class, name = "SAVINGS"),
@JsonSubTypes.Type(value = CurrentAccount.class, name = "CURRENT"),
@JsonSubTypes.Type(value = LoanAccount.class, name = "LOAN")
})
public sealed interface Account
permits SavingsAccount, CurrentAccount, LoanAccount {
String id();
BigDecimal balance();
}
How does Jackson polymorphic deserialization work?
Jackson uses @JsonTypeInfo and @JsonSubTypes to map a discriminator value to the correct concrete class. The discriminator is written as a property in the JSON.
package com.bank.model;
import java.math.BigDecimal;
public record SavingsAccount(String id, BigDecimal balance)
implements Account {}
public record CurrentAccount(String id, BigDecimal balance)
implements Account {}
public record LoanAccount(String id, BigDecimal balance)
implements Account {}
package com.bank.service;
import com.bank.model.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
public class JacksonSealedDemo {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Account savings = new SavingsAccount("SAV-1001", new BigDecimal("500000"));
// Serialize: writes the type discriminator
String json = mapper.writeValueAsString(savings);
System.out.println("JSON: " + json);
// Deserialize: reads the discriminator to pick the subtype
Account restored = mapper.readValue(json, Account.class);
System.out.println("Type: " + restored.getClass().getSimpleName());
System.out.println("Balance: " + restored.balance());
}
}
JSON: {"type":"SAVINGS","id":"SAV-1001","balance":500000}
Type: SavingsAccount
Balance: 500000
How does Gson handle sealed hierarchies?
Gson has no built-in polymorphic deserialization. You register a RuntimeTypeAdapterFactory from the extras companion.
package com.bank.service;
import com.bank.model.*;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.typeadapters.RuntimeTypeAdapterFactory;
import java.math.BigDecimal;
public class GsonSealedDemo {
public static void main(String[] args) {
RuntimeTypeAdapterFactory<Account> factory =
RuntimeTypeAdapterFactory.of(Account.class, "type")
.registerSubtype(SavingsAccount.class, "SAVINGS")
.registerSubtype(CurrentAccount.class, "CURRENT")
.registerSubtype(LoanAccount.class, "LOAN");
Gson gson = new GsonBuilder()
.registerTypeAdapterFactory(factory)
.create();
String json = "{\"type\":\"SAVINGS\",\"id\":\"SAV-1001\",\"balance\":500000}";
Account restored = gson.fromJson(json, Account.class);
System.out.println("Type: " + restored.getClass().getSimpleName());
}
}
What are the key caveats?
Four main caveats apply to serialization of sealed hierarchies.
| Caveat | Description |
|---|---|
| Discriminator required | Must embed a type property and agree on values across all producers and consumers |
| Subtype sync | Registered/annotated subtypes must stay in sync with permits |
| Record binding | Records have no setters; library must use canonical constructors |
| Security | Never enable Jackson default typing on untrusted JSON |
// DANGEROUS: default typing accepts arbitrary class names
// mapper.enableDefaultTyping(); // NEVER do this with untrusted JSON!
// SAFE: controlled Id.NAME mapping
// @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
// Only your known subtypes are accepted
How do records work as sealed leaves with Jackson?
Jackson binds records through their canonical constructor. Each component becomes a property. Since records have no setters, Jackson must use constructor-based binding, which it supports natively since Jackson 2.12.
package com.bank.model;
import java.math.BigDecimal;
// Record: immutable, no setters, constructor-based binding
public record SavingsAccount(String id, BigDecimal balance)
implements Account {
// Jackson uses the canonical constructor:
// SavingsAccount(String id, BigDecimal balance)
}
What about versioning across service boundaries?
Adding a new permitted subtype is a wire-compatibility change. Old consumers that do not know the new type will fail to deserialize it. Treat this like any schema evolution: version the discriminator, use a registry, and test backward compatibility.
// Version 1: Account permits SAVINGS, CURRENT, LOAN
// Version 2: Account permits SAVINGS, CURRENT, LOAN, FIXED_DEPOSIT
// Old consumer receiving {"type":"FIXED_DEPOSIT",...}:
// Jackson: InvalidTypeIdException (unknown type id)
// Gson: JsonParseException
// Solution: version the API, use a fallback handler
How does sealing help serialization?
Sealing helps because the closed permits list is the authoritative set the serializer must support. You can derive or validate the registered subtypes against it, and modern Jackson's inference leans on this.
package com.bank.service;
import com.bank.model.Account;
import java.util.Arrays;
public class PermittedSubtypeSync {
public static void validateSerializerConfig() {
Class<?>[] permitted = Account.class.getPermittedSubclasses();
System.out.println("Serializer must support:");
Arrays.stream(permitted)
.forEach(c -> System.out.println(" " + c.getSimpleName()));
}
}
What should a senior engineer know?
For internal, trusted JSON you can rely on Jackson's permits inference. For external or cross-organisation JSON, keep an explicit, reviewed discriminator mapping. In banking, Pankaj at National Bank of India never enables default typing on external APIs and always validates that the Jackson subtype annotations match the sealed permits list at startup.
Visual Overview
┌─────────────────────────────────────────────┐
│ Sealed Hierarchy Serialization Flow │
├─────────────────────────────────────────────┤
│ │
│ Serialize (Object → JSON) │
│ ┌────────────────────────┐ │
│ │ SavingsAccount │ │
│ │ id: "SAV-1001" │ │
│ │ balance: 500000 │ │
│ └──────────┬─────────────┘ │
│ ▼ │
│ {"type":"SAVINGS","id":"SAV-1001", │
│ "balance":500000} │
│ │
│ Deserialize (JSON → Object) │
│ ┌────────────────────────┐ │
│ │ Read "type" property │ │
│ │ "SAVINGS" │ │
│ └──────────┬─────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ Map to SavingsAccount │ │
│ │ via @JsonSubTypes or │ │
│ │ RuntimeTypeAdapter │ │
│ └──────────┬─────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ Bind via canonical │ │
│ │ constructor (record) │ │
│ └────────────────────────┘ │
└─────────────────────────────────────────────┘
Key Takeaway
Yes, you can serialize and deserialize a sealed hierarchy with Jackson or Gson, but the real work is in polymorphic deserialization. When the declared type is the sealed supertype, the library must decide which permitted subtype to build, and plain field data is often ambiguous. The solution is to embed a type discriminator in the JSON.
With Jackson, you put @JsonTypeInfo with the name strategy and a type property on the sealed type, and @JsonSubTypes listing each subtype mapping. Modern Jackson can infer the subtypes from the sealed permits clause. With Gson, register a RuntimeTypeAdapterFactory that maps discriminator values to each permitted subtype; without it, Gson cannot pick the concrete type.
The caveats are what matter most. First, you must agree on the discriminator name and values across all producers and consumers. Second, the registered subtypes must stay in sync with the sealed permits list. Third, records as leaves need constructor-based binding since they have no setters. Fourth, and most critical in banking, never enable Jackson default typing on untrusted JSON because accepting arbitrary class names is a classic deserialization attack.
What helps is that sealing provides an authoritative set the serializer must support. You can derive or validate the Jackson annotations against getPermittedSubclasses() at startup, and treat adding a new permitted subtype as a wire-compatibility change across service boundaries.
Remember one sentence: sealed hierarchies serialize freely, but polymorphic deserialization needs a type discriminator to pick the correct permitted subtype, and the registered subtype set must stay in sync with permits.
---