Semi-structured data carries a third state flat columns don't have: said-nothing vs said-null. A device that reported 'tip: null' told you the diner declined; a device that omitted the key may be an older kiosk that never asks — merging them invents a fact. Typed extraction (extract_string, ::INT casts) is where the merge happens: keep the raw JSON operator in the pipeline until after you've branched, then cast.
SELECT COUNT(*) AS declined FROM orders WHERE json_extract_string(payload, '$.tip') IS NULL;
SELECT COUNT(*) AS declined FROM orders WHERE json_type(json_extract(payload, '$.tip')) = 'NULL';
The declined-tip count read 2, not 1 — json_extract_string collapsed the kiosk that reported tip:null and the kiosk that never asked into one NULL, so a 'said no' and a 'wasn't asked' became the same fact.
Use the raw-JSON accessor (-> / json_type) to test the three states; cast late, after the distinction is made.
Operator spellings vary (-> / ->>, GET_PATH, JSON_VALUE); the three-state concept is portable — knowing your engine's collapsing function is the fluency.