Affiliation: this account is maintained by the SensorFlow team. This tutorial was drafted with AI assistance; its synthetic JavaScript fixture was executed locally. It is a standalone validation exercise, not a product capability claim or a production test.
An event arrives without campaign_id. A convenience fallback turns it into direct, and a chart now reports a business conclusion that the event never established. A missing property, an explicit null, an empty string, and a real identifier should not collapse into the same value simply because they are all inconvenient to group.
Event property validation should establish two separate facts: whether the property exists, and whether its value satisfies a documented contract. Only after those checks should a reporting rule assign a business meaning. A transport success response does not answer either question.
1. Write down what each state means
For a synthetic SignupCompleted event, this exercise uses the following policy:
- Absent
campaign_id: campaign context was not supplied. - Explicit
null: the sender supplied an explicit no-value marker. - Empty or whitespace-only string: a supplied identifier violates this contract.
- A nonblank string: syntactically valid campaign identifier.
- Any other type: invalid campaign identifier.
These are deliberately narrow labels. Explicit null does not prove consent was denied, and absence does not prove traffic was direct. If your source can distinguish “not applicable,” “not collected,” and “collection failed,” define separate reviewed fields or states. Do not reconstruct that evidence later from a generic null.
The JSON Schema null reference distinguishes a null value from an absent property. Its object reference also separates declared properties, required properties, and additional-property rules. Listing a property under properties alone does not make it required.
Our example accepts an absent or null campaign value but records its state. It requires an account identifier. That is a policy choice for this exercise, not a universal analytics schema.
2. Validate before choosing defaults
Save the complete example as property-contract.cjs and run node property-contract.cjs with a current Node.js installation. There are no dependencies or network calls. Every identifier below is synthetic.
const assert = require('node:assert/strict');
function fieldState(object, key) {
if (!Object.hasOwn(object, key)) return 'absent';
const value = object[key];
if (value === null) return 'explicit_null';
if (typeof value !== 'string') return 'invalid_type';
return value.trim() === '' ? 'empty' : 'value';
}
function validate(event) {
if (event === null || typeof event !== 'object' || Array.isArray(event)) {
throw new Error('event must be an object');
}
const allowed = new Set(['event', 'account_id', 'campaign_id']);
for (const key of Object.keys(event)) {
if (!allowed.has(key)) throw new Error(`unexpected field: ${key}`);
}
if (!Object.hasOwn(event, 'event') || event.event !== 'SignupCompleted') {
throw new Error('unexpected event');
}
if (fieldState(event, 'account_id') !== 'value') {
throw new Error('account_id must be a nonblank own string property');
}
const campaign = fieldState(event, 'campaign_id');
if (campaign === 'empty' || campaign === 'invalid_type') {
throw new Error(`invalid campaign_id: ${campaign}`);
}
return { account_id: event.account_id, campaign_state: campaign };
}
const base = { event: 'SignupCompleted', account_id: 'synthetic-account-a' };
const valid = [base, { ...base, campaign_id: null },
{ ...base, campaign_id: 'synthetic-summer' }];
assert.deepEqual(valid.map(e => validate(e).campaign_state),
['absent', 'explicit_null', 'value']);
assert.throws(() => validate({ ...base, campaign_id: '' }));
assert.throws(() => validate({ ...base, campaign_id: ' ' }));
assert.throws(() => validate({ ...base, campaign_id: 0 }));
assert.throws(() => validate({ ...base, campaign_id: false }));
assert.throws(() => validate({ ...base, campaign_id: undefined }));
assert.throws(() => validate({ ...base, account_id: null }));
assert.throws(() => validate({ ...base, account_id: '' }));
assert.throws(() => validate({ event: 'SignupCompleted' }));
assert.throws(() => validate({ ...base, event: 'SignupClicked' }));
assert.throws(() => validate({ ...base, campain_id: 'typo' }));
assert.throws(() => validate([]));
assert.equal(fieldState(Object.create({ campaign_id: 'inherited' }), 'campaign_id'), 'absent');
assert.equal(validate(JSON.parse(JSON.stringify({ ...base, campaign_id: undefined })))
.campaign_state, 'absent');
assert.equal(validate(JSON.parse(JSON.stringify({ ...base, campaign_id: null })))
.campaign_state, 'explicit_null');
console.log(valid.map(validate));
console.log('PASS: 15 synthetic acceptance checks');
The three accepted samples retain three different campaign states. An empty string, wrong type, misspelled extra field, missing account, or wrong event name fails loudly instead of becoming a plausible-looking chart category.
The validator does not silently trim and rewrite the identifier. Whitespace-only strings fail, while other nonblank strings remain unchanged. A production contract may impose additional length or character restrictions; add them explicitly with matching tests.
3. Test the value that actually crosses the wire
One assertion is intentionally surprising: an own property containing JavaScript undefined fails this validator before serialization, but becomes absent after a JSON round trip. That is not a contradiction. These are two different objects at two different boundaries.
MDN documents JSON.stringify's conversion rules: undefined-valued object properties are omitted; array elements behave differently. A sender-side object inspection therefore cannot replace checking the serialized request and the parsed receiver-side event.
Test both boundaries when your application builds a payload in JavaScript. This does not mean every SDK uses JSON.stringify directly; SDK transformations, batching, and encoding must be verified separately. The fixture is not an SDK transport payload or a receiver integration test.
The helper uses Object.hasOwn rather than truthiness or prototype-chain membership. That keeps absent fields separate from present nulls and avoids treating inherited properties as collected data. It is not a substitute for parsing untrusted input safely or for application authorization.
4. Decide where strictness belongs
Rejecting unexpected fields helps expose the typo campain_id. It can also reject a legitimate new field during a rolling client deployment. This example chooses a closed, version-specific contract to make the failure observable; it does not recommend abruptly applying that policy to every production event.
For production, decide how old and new producer versions coexist before tightening the receiver. A quarantine path, a versioned schema, and error counters may be more appropriate than dropping a whole batch. Preserve enough safe diagnostic context to investigate, but do not copy arbitrary personal data into validation logs.
The fixture uses Node's strict assertion module so a broken expectation stops the run. Its tests verify the listed rules, not semantic truth. A nonblank account ID can still identify the wrong account; a well-formed campaign ID can still have incorrect attribution. Those need trusted business context and integration tests.
5. Keep unknown data visible in the report
After validation, report missing context separately from known campaign values. If you later group absent and explicit-null states into a single “unknown” display category, preserve the original state for investigation. Do not rename “unknown” to “direct” unless separate attribution evidence supports that interpretation.
For a practical review, check one valid value, one absent field, one explicit null, one empty value, one wrong type, and one misspelled property. Compare the in-memory event, serialized request, parsed event, and final reporting category. A small fixture with a stated contract is more useful than a chart whose defaults quietly invent meaning.
Top comments (0)