Tampilan
Kenapa penting
NullPointerException adalah bug paling umum di Java — kamu sudah bertemu dengannya di M03 saat map.get(key) mengembalikan null lalu di-unboxing. Akar masalahnya: signature Product findBySku(String sku) tidak memberi tahu bahwa hasilnya bisa null, jadi pemanggil mudah lupa mengecek. Optional<T> membuat kemungkinan "tidak ada" itu terlihat di tipe: compiler memaksa pemanggil memutuskan apa yang terjadi saat nilainya kosong. Di Spring Data, setiap findById mengembalikan Optional, sehingga pola findById(id).orElseThrow(...) akan kamu tulis di hampir setiap service. Memakainya dengan benar membuat kode lebih aman; memakainya dengan salah justru menambah kerumitan.
Konsep inti
Untuk apa Optional dibuat
Optional adalah wadah yang berisi tepat satu nilai atau kosong. Desainer API Java menyatakan tujuannya sempit: sebagai return type method yang hasilnya mungkin tidak ada, terutama bila null rawan disalahartikan. Ia bukan pengganti semua null di program, dan bukan alat untuk menghindari if.
| Situasi | Pilihan yang dianjurkan |
|---|---|
| Cari satu data yang mungkin tidak ada | Optional<T> sebagai return type |
| Cari banyak data, mungkin tidak ada | List<T> kosong |
| Field class / komponen record | tipe biasa (boleh null secara internal) |
| Parameter method | tipe biasa, atau buat dua method (overload) |
Membuat Optional: of, ofNullable, empty
java
import java.util.Optional;
public class CreateDemo {
public static void main(String[] args) {
Optional<String> sku = Optional.of("KOPI-250");
Optional<String> none = Optional.empty();
String fromRequest = null;
Optional<String> maybe = Optional.ofNullable(fromRequest);
System.out.println(sku + " " + none + " " + maybe);
System.out.println(sku.isPresent() + " " + maybe.isEmpty());
Optional<String> boom = Optional.of(fromRequest);
}
}Output:
text
Optional[KOPI-250] Optional.empty Optional.empty
true true
Exception in thread "main" java.lang.NullPointerException
at java.base/java.util.Objects.requireNonNull(Objects.java:220)
at java.base/java.util.Optional.of(Optional.java:113)
at CreateDemo.main(CreateDemo.java:13)Optional.of(x)— untuk nilai yang pasti tidaknull; bila ternyatanull, ia langsung gagal. Itu bagus: kesalahan ketahuan di titik asalnya.Optional.ofNullable(x)— untuk nilai yang mungkinnull, misalnya hasilmap.get(...)atau API lama.Optional.empty()— untuk menyatakan "tidak ada" secara eksplisit.
Mengolah isi: map, flatMap, filter
Seperti stream, Optional punya operasi yang hanya berjalan bila ada nilai. Bila kosong, operasinya dilewati dan hasilnya tetap kosong — tanpa if (x != null) bertingkat.
java
import java.util.Optional;
record Address(String city) {}
record Customer(String name, String email, Address address) { // address boleh null di data
Optional<Address> shippingAddress() {
return Optional.ofNullable(address);
}
}
public class TransformDemo {
public static void main(String[] args) {
Optional<Customer> found = Optional.of(new Customer("Sari", " SARI@Mail.com ", new Address("Bandung")));
Optional<Customer> missing = Optional.empty();
Optional<String> email = found.map(Customer::email).map(String::strip).map(String::toLowerCase);
System.out.println(email);
System.out.println(missing.map(Customer::email));
Optional<Optional<Address>> nested = found.map(Customer::shippingAddress);
Optional<String> city = found.flatMap(Customer::shippingAddress).map(Address::city);
System.out.println(nested + " | " + city);
Optional<Customer> corporate = found.filter(c -> c.email().contains("@corp.id"));
System.out.println(corporate);
}
}Output:
text
Optional[sari@mail.com]
Optional.empty
Optional[Optional[Address[city=Bandung]]] | Optional[Bandung]
Optional.emptymap(f)— ubah isi denganf; hasilfdibungkusOptionalbaru.flatMap(f)— untukfyang sudah mengembalikanOptional, agar tidak bersarang menjadiOptional<Optional<...>>. Logikanya sama persis denganflatMappada stream di bab sebelumnya.filter(p)— pertahankan nilai hanya bila lolos predicate; jika tidak, hasilnya kosong.
Perhatikan desain Customer: komponen record address bertipe biasa, sedangkan method shippingAddress() yang mengembalikan Optional. Inilah pola yang dianjurkan — Optional di batas API, bukan di penyimpanan data.
Mengambil nilai: orElse, orElseGet, orElseThrow, ifPresentOrElse
java
import java.util.NoSuchElementException;
import java.util.Optional;
public class OrElseDemo {
static String loadDefaultTier() {
System.out.println(" (query default tier ke database...)");
return "BRONZE";
}
public static void main(String[] args) {
Optional<String> tier = Optional.of("GOLD");
System.out.println("orElse:");
System.out.println(tier.orElse(loadDefaultTier()));
System.out.println("orElseGet:");
System.out.println(tier.orElseGet(OrElseDemo::loadDefaultTier));
Optional<String> none = Optional.empty();
System.out.println(none.orElseGet(OrElseDemo::loadDefaultTier));
tier.ifPresentOrElse(
t -> System.out.println("Diskon untuk " + t),
() -> System.out.println("Tanpa diskon"));
none.ifPresentOrElse(
t -> System.out.println("Diskon untuk " + t),
() -> System.out.println("Tanpa diskon"));
try {
none.get();
} catch (NoSuchElementException e) {
System.out.println(e);
}
String result = none.orElseThrow(() -> new IllegalArgumentException("Tier tidak ditemukan"));
}
}Output (stack trace dipotong):
text
orElse:
(query default tier ke database...)
GOLD
orElseGet:
GOLD
(query default tier ke database...)
BRONZE
Diskon untuk GOLD
Tanpa diskon
java.util.NoSuchElementException: No value present
Exception in thread "main" java.lang.IllegalArgumentException: Tier tidak ditemukan
at OrElseDemo.lambda$main$4(OrElseDemo.java:34)Baris kedua output adalah pelajaran terpenting bab ini: orElse(x) selalu mengevaluasi x, walaupun Optional berisi nilai, karena x dihitung sebelum method dipanggil. Untuk konstanta (orElse(0L), orElse("-")) itu tidak masalah. Untuk fallback mahal — query database, panggilan HTTP, membuat object besar — pakai orElseGet(supplier) yang hanya memanggil supplier saat kosong.
| Method | Saat kosong |
|---|---|
orElse(value) | kembalikan value (selalu dievaluasi) |
orElseGet(supplier) | panggil supplier (hanya saat kosong) |
orElseThrow(supplier) | lempar exception buatan supplier |
orElseThrow() | lempar NoSuchElementException (Java 10) |
ifPresentOrElse(action, alt) | jalankan alt (Java 9) |
get() | lempar NoSuchElementException — hindari |
Anti-pattern yang sering muncul di code review
1. get() tanpa cek. findById(id).get() sama saja dengan mengabaikan null — hanya nama exception-nya yang berganti. Bahkan if (opt.isPresent()) { opt.get() ... } biasanya bisa ditulis lebih jelas dengan map, ifPresentOrElse, atau orElseThrow.
2. Optional sebagai field atau parameter. Optional tidak Serializable dan menambah satu object per nilai. Sebagai parameter, ia memaksa pemanggil menulis Optional.empty() — dan pemanggil tetap bisa mengoper null untuk parameter bertipe Optional. Simpan field bertipe biasa, lalu tawarkan getter yang mengembalikan Optional seperti shippingAddress() di atas. Untuk parameter opsional, buat overload.
java
// Potongan — hindari
record Customer(String name, Optional<String> phone) {}
void sendInvoice(Order order, Optional<String> ccEmail) { /* ... */ }
// Lebih baik
record Customer(String name, String phone) {
Optional<String> phoneNumber() { return Optional.ofNullable(phone); }
}
void sendInvoice(Order order) { /* ... */ }
void sendInvoice(Order order, String ccEmail) { /* ... */ }3. Optional<List<T>> (atau Optional<Map>, Optional<Set>). Collection kosong sudah berarti "tidak ada". Kembalikan List.of() / list kosong.
4. Mengembalikan null dari method bertipe Optional. Ini merusak seluruh kontrak — pemanggil yakin tidak perlu cek null. Selalu kembalikan Optional.empty().
Contoh di dunia backend
Di Spring Data (dibahas di level Spring Boot), repository cukup berupa interface dan method findById warisan CrudRepository mengembalikan Optional<T>. Method query buatan sendiri yang mencari satu baris juga boleh dideklarasikan dengan Optional:
java
// Potongan — gaya Spring Data JPA, dibahas di level Spring Boot
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findBySku(String sku); // satu baris, mungkin tidak ada
List<Product> findByNameContaining(String keyword); // banyak baris → list, bisa kosong
}Supaya bisa dijalankan sekarang tanpa framework, contoh berikut meniru kontrak itu dengan repository in-memory. Service mengubah "tidak ditemukan" menjadi exception domain, dan menghitung harga setelah voucher yang kodenya opsional di request.
java
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
record Product(long id, String sku, String name, long price, boolean active) {}
record Voucher(String code, long discount, boolean expired) {}
record ProductResponse(String sku, String name, long price) {}
class ProductNotFoundException extends RuntimeException {
ProductNotFoundException(long id) {
super("Produk dengan id " + id + " tidak ditemukan");
}
}
interface ProductRepository { // meniru gaya Spring Data
Optional<Product> findById(long id);
List<Product> findByNameContaining(String keyword);
}
interface VoucherRepository {
Optional<Voucher> findByCode(String code);
}
class InMemoryProductRepository implements ProductRepository {
private final Map<Long, Product> rows;
InMemoryProductRepository(List<Product> products) {
rows = products.stream().collect(Collectors.toMap(Product::id, Function.identity()));
}
@Override
public Optional<Product> findById(long id) {
return Optional.ofNullable(rows.get(id));
}
@Override
public List<Product> findByNameContaining(String keyword) {
return rows.values().stream().filter(p -> p.name().contains(keyword)).toList(); // kosong, bukan null
}
}
public class OrderService {
private final ProductRepository products;
private final VoucherRepository vouchers;
OrderService(ProductRepository products, VoucherRepository vouchers) {
this.products = products;
this.vouchers = vouchers;
}
ProductResponse getProduct(long id) {
return products.findById(id)
.filter(Product::active)
.map(p -> new ProductResponse(p.sku(), p.name(), p.price()))
.orElseThrow(() -> new ProductNotFoundException(id));
}
long priceAfterVoucher(long productId, String voucherCode) {
long price = getProduct(productId).price();
long discount = Optional.ofNullable(voucherCode) // kode voucher di request boleh kosong
.flatMap(vouchers::findByCode)
.filter(v -> !v.expired())
.map(Voucher::discount)
.orElse(0L);
return Math.max(0, price - discount);
}
public static void main(String[] args) {
ProductRepository productRepo = new InMemoryProductRepository(List.of(
new Product(1, "KOPI-250", "Kopi Gayo 250 g", 85_000, true),
new Product(2, "TEH-100", "Teh Melati 100 g", 40_000, false)));
Map<String, Voucher> voucherRows = Map.of(
"HEMAT10", new Voucher("HEMAT10", 10_000, false),
"LAMA5", new Voucher("LAMA5", 5_000, true));
VoucherRepository voucherRepo = code -> Optional.ofNullable(voucherRows.get(code));
OrderService service = new OrderService(productRepo, voucherRepo);
System.out.println(service.getProduct(1));
System.out.println(service.priceAfterVoucher(1, "HEMAT10"));
System.out.println(service.priceAfterVoucher(1, "LAMA5"));
System.out.println(service.priceAfterVoucher(1, null));
System.out.println(productRepo.findByNameContaining("Susu"));
for (long id : new long[] {2, 99}) {
try {
service.getProduct(id);
} catch (ProductNotFoundException e) {
System.out.println(e.getMessage());
}
}
}
}Output:
text
ProductResponse[sku=KOPI-250, name=Kopi Gayo 250 g, price=85000]
75000
85000
85000
[]
Produk dengan id 2 tidak ditemukan
Produk dengan id 99 tidak ditemukanRantai di getProduct dibaca dari atas ke bawah: cari produk, abaikan yang nonaktif (produk nonaktif diperlakukan sama dengan tidak ada), petakan ke DTO, dan lempar exception domain bila kosong. Nanti di Spring Boot, ProductNotFoundException dipetakan ke HTTP 404 oleh exception handler global. Di priceAfterVoucher, kode voucher null, voucher yang tidak dikenal, dan voucher kedaluwarsa semuanya jatuh ke orElse(0L) — tiga kasus tanpa satu pun if. orElse aman di sini karena 0L konstanta murah.
Pertanyaan interview
1. Apa beda orElse dan orElseGet?
Keduanya mengembalikan nilai cadangan saat Optional kosong. Bedanya, argumen orElse dievaluasi selalu, bahkan saat Optional berisi nilai, sedangkan orElseGet menerima Supplier yang hanya dipanggil saat kosong. Untuk konstanta pakai orElse; untuk fallback yang mahal atau punya efek samping pakai orElseGet.
2. Kenapa Optional tidak dianjurkan sebagai field atau parameter?
Optional dirancang untuk return type. Sebagai field ia tidak Serializable, menambah alokasi object, dan tidak cocok dengan banyak framework (JPA, serialisasi JSON). Sebagai parameter ia membebani pemanggil dan tetap bisa diisi null. Alternatifnya: field biasa dengan getter yang mengembalikan Optional, dan overload method untuk parameter opsional.
3. Bagaimana menangani hasil findById yang kosong di service?
Gunakan findById(id).orElseThrow(() -> new XxxNotFoundException(id)) agar kasus tidak ditemukan menjadi exception domain yang jelas dan bisa dipetakan ke HTTP 404. Hindari get() (exception tidak bermakna, jadi 500) dan orElse(null) (mengembalikan masalah null ke pemanggil).
Jebakan umum
opt.get()tanpa cek →NoSuchElementException: No value present. PakaiorElseThrowdengan pesan yang jelas.orElse(panggilanMahal())— fallback tetap dijalankan walau nilainya ada. PakaiorElseGet.Optional.of(nilaiYangBisaNull)→NullPointerException. PakaiofNullableuntuk data dari luar.Optional<List<T>>— kembalikan list kosong saja.Optionalsebagai field record/entity atau parameter method — simpan tipe biasa,Optionalhanya di return type.- Method bertipe
Optionalyang mengembalikannull— selalu kembalikanOptional.empty().
Ringkasan
Optional<T>membuat "mungkin tidak ada" terlihat di tipe; pakai terutama sebagai return type.ofuntuk nilai pasti ada,ofNullableuntuk nilai yang bisanull,emptyuntuk "tidak ada".map,flatMap, danfiltermengolah isi tanpaifbertingkat;flatMapuntuk fungsi yang mengembalikanOptional.orElseselalu mengevaluasi argumennya,orElseGethanya saat kosong;orElseThrowuntuk exception domain.- Hindari
get()tanpa cek,Optionalsebagai field/parameter, danOptional<List>. - Pola service:
repository.findById(id).orElseThrow(() -> new NotFoundException(id)).