Back to Sealed Classes & Interfaces
Q8Question 8 of 12

Can you discover all subclasses of a sealed class at runtime (`getPermittedSubclasses()`)?

Key Concepts
  • `Class.getPermittedSubclasses()` returns an array of `Class` objects for the direct permitted subtypes recorded in the class file.
  • It returns `null` if the class is not sealed; guard with `Class.isSealed()` first.
  • The method returns only direct permitted subtypes, not transitive descendants.
  • For a `non-sealed` branch, the open, unlisted subclasses cannot be enumerated reflectively.
  • Complete runtime discovery is only possible for hierarchies that are closed all the way down (`final`/`sealed`).
  • Frameworks use this to auto-register serializers, validators, and test cases for each permitted subtype.
  • The permitted set is authoritative and trustworthy: it is validated by the JVM at load time.

Definition

`getPermittedSubclasses()` returns the direct permitted subtypes recorded in the class file at runtime, but only the direct ones, not transitive descendants or open `non-sealed` subclasses.
  • Class.getPermittedSubclasses() – returns a Class[] of the direct permitted subtypes recorded in the PermittedSubclasses class-file attribute, added in Java 17.
  • Class.isSealed() – returns true if the class is sealed; use it as a guard before calling getPermittedSubclasses().
  • Direct only – returns only direct permitted subtypes, not grandchildren or deeper descendants.
  • non-sealed limitation – cannot enumerate the open, unlisted subclasses beneath a non-sealed branch.
  • Authoritative – the JVM validates the permitted set at load time, so the reflective view is trustworthy.

Imagine National Bank of India keeps the official closed list of which products may directly be "an Account" filed in head office records. At any time a clerk can pull that exact list and read off the approved direct products. But it only lists the direct products; it does not chase down every sub-variant a product might have, and if one product was stamped "open" for partners, the list cannot name the unknown partner products.

Understanding the Concepts

How do you use getPermittedSubclasses?

Call Class.isSealed() to check if a type is sealed, then call getPermittedSubclasses() to get the array of direct permitted subtypes.

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.util.Arrays;

public class PermittedDiscovery {

    sealed interface Account
            permits SavingsAccount, CurrentAccount, LoanAccount {
        BigDecimal balance();
    }

    final record SavingsAccount(BigDecimal balance) implements Account {}
    final record CurrentAccount(BigDecimal balance) implements Account {}
    final record LoanAccount(BigDecimal balance) implements Account {}

    public static void main(String[] args) {
        System.out.println("isSealed: " + Account.class.isSealed());

        Class<?>[] permitted = Account.class.getPermittedSubclasses();
        System.out.println("Permitted subtypes:");
        Arrays.stream(permitted)
                .forEach(c -> System.out.println("  " + c.getSimpleName()));
    }
}
TEXT
isSealed: true
Permitted subtypes:
  SavingsAccount
  CurrentAccount
  LoanAccount

Why does it return null for non-sealed types?

If the class is not sealed, getPermittedSubclasses() returns null. Always guard with isSealed() first.

JAVA
package com.bank.service;

public class NullCheck {
    public static void main(String[] args) {
        // String is not sealed
        System.out.println("String isSealed: " + String.class.isSealed());
        System.out.println("Permitted: " + String.class.getPermittedSubclasses());
    }
}
TEXT
String isSealed: false
Permitted: null

How do you discover the full leaf set recursively?

The method returns only direct permitted subtypes. To get the complete leaf set, recurse: for each subtype, if it is itself sealed, call getPermittedSubclasses() again.

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class RecursiveDiscovery {

    sealed interface Account
            permits SavingsAccount, LoanAccount {
        BigDecimal balance();
    }

    final record SavingsAccount(BigDecimal balance) implements Account {}

    sealed class LoanAccount implements Account
            permits HomeLoan, CarLoan {
        @Override public BigDecimal balance() { return BigDecimal.ZERO; }
    }
    final class HomeLoan extends LoanAccount {}
    final class CarLoan extends LoanAccount {}

    public static List<Class<?>> findAllLeaves(Class<?> sealedType) {
        List<Class<?>> leaves = new ArrayList<>();
        if (!sealedType.isSealed()) {
            leaves.add(sealedType);
            return leaves;
        }
        for (Class<?> sub : sealedType.getPermittedSubclasses()) {
            if (sub.isSealed()) {
                leaves.addAll(findAllLeaves(sub));
            } else {
                leaves.add(sub);
            }
        }
        return leaves;
    }

    public static void main(String[] args) {
        List<Class<?>> leaves = findAllLeaves(Account.class);
        System.out.println("Leaf types:");
        leaves.forEach(c -> System.out.println("  " + c.getSimpleName()));
    }
}
TEXT
Leaf types:
  SavingsAccount
  HomeLoan
  CarLoan

What happens with non-sealed branches?

For a non-sealed branch, the open subclasses are not recorded anywhere and can be loaded at any time. The recursion must stop at non-sealed types and report them as open branches.

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.lang.reflect.Modifier;

public class NonSealedBranch {

    sealed interface Account
            permits SavingsAccount, PartnerProduct {
        BigDecimal balance();
    }

    final record SavingsAccount(BigDecimal balance) implements Account {}

    non-sealed class PartnerProduct implements Account {
        @Override public BigDecimal balance() { return BigDecimal.ZERO; }
    }

    public static void main(String[] args) {
        for (Class<?> sub : Account.class.getPermittedSubclasses()) {
            if (sub.isSealed()) {
                System.out.println(sub.getSimpleName() + ": sealed (recurse)");
            } else if (Modifier.isFinal(sub.getModifiers())) {
                System.out.println(sub.getSimpleName() + ": final (leaf)");
            } else {
                System.out.println(sub.getSimpleName()
                        + ": non-sealed (open, cannot enumerate)");
            }
        }
    }
}
TEXT
SavingsAccount: final (leaf)
PartnerProduct: non-sealed (open, cannot enumerate)

How do frameworks use this for auto-registration?

Frameworks can derive serializer mappings, validation rules, or test cases directly from getPermittedSubclasses(), so the configuration never drifts out of sync with the sealed permits list.

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

public class AutoRegistration {

    sealed interface Account
            permits SavingsAccount, CurrentAccount {
        BigDecimal balance();
    }
    final record SavingsAccount(BigDecimal balance) implements Account {}
    final record CurrentAccount(BigDecimal balance) implements Account {}

    // Auto-derive subtype mapping from sealed permits
    public static Map<String, Class<?>> buildSubtypeMap(Class<?> sealedType) {
        Map<String, Class<?>> map = new HashMap<>();
        if (sealedType.isSealed()) {
            for (Class<?> sub : sealedType.getPermittedSubclasses()) {
                map.put(sub.getSimpleName().toUpperCase(), sub);
            }
        }
        return map;
    }

    public static void main(String[] args) {
        Map<String, Class<?>> map = buildSubtypeMap(Account.class);
        System.out.println("Auto-registered subtypes:");
        map.forEach((k, v) -> System.out.println("  " + k + " -> " + v.getSimpleName()));
    }
}
TEXT
Auto-registered subtypes:
  SAVINGSACCOUNT -> SavingsAccount
  CURRENTACCOUNT -> CurrentAccount

Is the reflective data trustworthy?

Yes. The permitted set is recorded in the PermittedSubclasses class-file attribute and validated by the JVM at load time. It cannot be fooled by a class that was not actually permitted. The reflective view is authoritative.

JAVA
// The JVM validates at load time:
// 1. Each permitted subclass actually extends/implements the sealed type
// 2. Both are in the same module
// 3. The subclass declares final, sealed, or non-sealed
// So getPermittedSubclasses() is trustworthy

What are common mistakes with runtime sealed class discovery?

The most common mistake is calling getPermittedSubclasses() on a non-sealed class and not checking for null, which causes a NullPointerException. Another error is assuming the returned array contains loaded classes; it returns Class descriptors that may not yet be loaded. A third mistake is relying on the reflective data for security decisions without verifying that the classes are actually sealed at the bytecode level.

JAVA
package com.bank.model;

public class SealedDiscoveryMistakes {
    // Non-sealed class
    static class RegularClass { }

    sealed static class Account permits Account.Savings {
        static final class Savings extends Account { }
    }

    public static void main(String[] args) {
        // WRONG: no null check on non-sealed class
        // Class<?>[] subs = RegularClass.class.getPermittedSubclasses();
        // subs.length -> NullPointerException!

        // CORRECT: null check first
        Class<?>[] subs = RegularClass.class.getPermittedSubclasses();
        if (subs == null) {
            System.out.println("RegularClass is not sealed: null returned");
        }

        // CORRECT: sealed class returns non-null array
        Class<?>[] accountSubs = Account.class.getPermittedSubclasses();
        if (accountSubs != null) {
            System.out.println("Account permitted subclasses: " + accountSubs.length);
            for (Class<?> sub : accountSubs) {
                System.out.println("  " + sub.getSimpleName());
            }
        }
    }
}
TEXT
Expected output:
RegularClass is not sealed: null returned
Account permitted subclasses: 1
  Savings

What should a senior engineer know?

Combine reflective discovery (getPermittedSubclasses()) with compile-time exhaustiveness (switch). The reflective approach is for framework code that auto-wires based on the sealed set at startup. The switch approach is for application code that handles each variant. Together they give both automatic framework wiring and compiler-checked application logic over the same sealed hierarchy.

Visual Overview

DIAGRAM
┌─────────────────────────────────────────────┐
│  Recursive Permitted Subclass Discovery     │
├─────────────────────────────────────────────┤
│                                             │
│  Account.class.getPermittedSubclasses()     │
│       │                                     │
│       ├── SavingsAccount (final)            │
│       │   └── LEAF ✓                        │
│       │                                     │
│       └── LoanAccount (sealed)              │
│           │                                 │
│           ├── HomeLoan (final)              │
│           │   └── LEAF ✓                    │
│           │                                 │
│           └── CarLoan (final)               │
│               └── LEAF ✓                    │
│                                             │
│  Result: [SavingsAccount, HomeLoan, CarLoan]│
│                                             │
│  Non-sealed branch:                         │
│  ┌──────────────────────────────┐           │
│  │ PartnerProduct (non-sealed)  │           │
│  │ └── STOP: open, cannot       │           │
│  │     enumerate subtypes       │           │
│  └──────────────────────────────┘           │
└─────────────────────────────────────────────┘

Key Takeaway

Class.getPermittedSubclasses(), added in Java 17, returns an array of Class objects for the direct permitted subtypes that the compiler recorded in the PermittedSubclasses attribute. It returns null if the class is not sealed, so you guard with isSealed() first. Because the permitted set is validated by the JVM at load time, this reflective view is authoritative.

The key limitation is that it returns only the direct permitted subtypes. If a permitted subtype is itself sealed, you must recurse to discover the full leaf set. In a banking project at National Bank of India, Account permits LoanAccount, which is itself sealed permitting HomeLoan and CarLoan. Calling getPermittedSubclasses() on Account returns only LoanAccount, not its leaves, so Rajesh recurses to find all three leaves.

The recursion must stop at a non-sealed branch, because its subclasses are open and cannot be enumerated. Complete runtime discovery is only possible for hierarchies that are closed all the way down with final and sealed types.

The practical payoff is framework auto-registration. A serialization layer can derive Jackson subtype mappings directly from getPermittedSubclasses(), so the mapping never drifts out of sync with the sealed permits list. When a new product is added, the registration picks it up automatically. This pairs beautifully with compile-time exhaustiveness of switches, giving both automatic framework wiring and compiler-checked application code.

Remember one sentence: getPermittedSubclasses() returns the direct permitted subtypes at runtime, but you must recurse for the full leaf set and cannot enumerate beyond a non-sealed branch.

---

Share
Use ← → arrow keys to navigate