From bf818f6ae18ae3c64be3ab3834983637ac06d0ed Mon Sep 17 00:00:00 2001 From: kyungseopk1m Date: Thu, 23 Apr 2026 16:55:12 +0900 Subject: [PATCH 1/2] feat(firestore): add public type guards for FieldValue sentinels --- handwritten/firestore/dev/src/field-value.ts | 104 +++++++++++++++++ handwritten/firestore/dev/test/field-value.ts | 105 ++++++++++++++++++ handwritten/firestore/types/firestore.d.ts | 44 ++++++++ 3 files changed, 253 insertions(+) diff --git a/handwritten/firestore/dev/src/field-value.ts b/handwritten/firestore/dev/src/field-value.ts index 46d4a9794420..0bd01cf6f66b 100644 --- a/handwritten/firestore/dev/src/field-value.ts +++ b/handwritten/firestore/dev/src/field-value.ts @@ -245,6 +245,100 @@ export class FieldValue implements firestore.FieldValue { return new ArrayRemoveTransform(elements); } + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.serverTimestamp}. + * + * @param value The value to check. + * @returns `true` if `value` is a server-timestamp sentinel. + * + * @example + * ``` + * const sentinel = Firestore.FieldValue.serverTimestamp(); + * Firestore.FieldValue.isServerTimestamp(sentinel); // true + * Firestore.FieldValue.isServerTimestamp(new Date()); // false + * ``` + */ + static isServerTimestamp(value: unknown): value is FieldValue { + return value instanceof ServerTimestampTransform; + } + + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.increment}. This includes sentinels produced with a + * negative operand; use {@link FieldValue.isDecrement} to narrow further + * to decrement-style operations. + * + * @param value The value to check. + * @returns `true` if `value` is an increment sentinel. + * + * @example + * ``` + * Firestore.FieldValue.isIncrement(Firestore.FieldValue.increment(1)); // true + * Firestore.FieldValue.isIncrement(Firestore.FieldValue.increment(-1)); // true + * Firestore.FieldValue.isIncrement(1); // false + * ``` + */ + static isIncrement(value: unknown): value is FieldValue { + return value instanceof NumericIncrementTransform; + } + + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.increment} with a negative operand. Every value that + * satisfies `isDecrement` also satisfies {@link FieldValue.isIncrement}. + * + * @param value The value to check. + * @returns `true` if `value` is an increment sentinel with a negative + * operand. + * + * @example + * ``` + * Firestore.FieldValue.isDecrement(Firestore.FieldValue.increment(-1)); // true + * Firestore.FieldValue.isDecrement(Firestore.FieldValue.increment(1)); // false + * Firestore.FieldValue.isDecrement(Firestore.FieldValue.increment(0)); // false + * ``` + */ + static isDecrement(value: unknown): value is FieldValue { + return ( + value instanceof NumericIncrementTransform && value.isDecrementSentinel + ); + } + + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.arrayUnion}. + * + * @param value The value to check. + * @returns `true` if `value` is an array-union sentinel. + * + * @example + * ``` + * Firestore.FieldValue.isArrayUnion(Firestore.FieldValue.arrayUnion('a')); // true + * Firestore.FieldValue.isArrayUnion(['a']); // false + * ``` + */ + static isArrayUnion(value: unknown): value is FieldValue { + return value instanceof ArrayUnionTransform; + } + + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.arrayRemove}. + * + * @param value The value to check. + * @returns `true` if `value` is an array-remove sentinel. + * + * @example + * ``` + * Firestore.FieldValue.isArrayRemove(Firestore.FieldValue.arrayRemove('a')); // true + * Firestore.FieldValue.isArrayRemove(['a']); // false + * ``` + */ + static isArrayRemove(value: unknown): value is FieldValue { + return value instanceof ArrayRemoveTransform; + } + /** * Returns true if this `FieldValue` is equal to the provided value. * @@ -440,6 +534,16 @@ class NumericIncrementTransform extends FieldTransform { super(); } + /** + * Whether this increment sentinel was constructed with a negative operand. + * + * @private + * @internal + */ + get isDecrementSentinel(): boolean { + return this.operand < 0; + } + /** * Numeric transforms are omitted from document masks. * diff --git a/handwritten/firestore/dev/test/field-value.ts b/handwritten/firestore/dev/test/field-value.ts index 1387967415e4..e089c5d0d4ed 100644 --- a/handwritten/firestore/dev/test/field-value.ts +++ b/handwritten/firestore/dev/test/field-value.ts @@ -279,3 +279,108 @@ describe('FieldValue.serverTimestamp()', () => { FieldValue.serverTimestamp(), ); }); + +describe('FieldValue sentinel type guards', () => { + const serverTimestampSentinel = FieldValue.serverTimestamp(); + const positiveIncrement = FieldValue.increment(5); + const negativeIncrement = FieldValue.increment(-5); + const zeroIncrement = FieldValue.increment(0); + const arrayUnionSentinel = FieldValue.arrayUnion('foo'); + const arrayRemoveSentinel = FieldValue.arrayRemove('foo'); + const deleteSentinel = FieldValue.delete(); + + describe('isServerTimestamp()', () => { + it('matches only server-timestamp sentinels', () => { + expect(FieldValue.isServerTimestamp(serverTimestampSentinel)).to.be.true; + expect(FieldValue.isServerTimestamp(positiveIncrement)).to.be.false; + expect(FieldValue.isServerTimestamp(arrayUnionSentinel)).to.be.false; + expect(FieldValue.isServerTimestamp(arrayRemoveSentinel)).to.be.false; + expect(FieldValue.isServerTimestamp(deleteSentinel)).to.be.false; + }); + + it('returns false for non-sentinel values', () => { + expect(FieldValue.isServerTimestamp(undefined)).to.be.false; + expect(FieldValue.isServerTimestamp(null)).to.be.false; + expect(FieldValue.isServerTimestamp(0)).to.be.false; + expect(FieldValue.isServerTimestamp('serverTimestamp')).to.be.false; + expect(FieldValue.isServerTimestamp({})).to.be.false; + expect(FieldValue.isServerTimestamp(new Date())).to.be.false; + }); + }); + + describe('isIncrement()', () => { + it('matches any increment sentinel regardless of sign', () => { + expect(FieldValue.isIncrement(positiveIncrement)).to.be.true; + expect(FieldValue.isIncrement(negativeIncrement)).to.be.true; + expect(FieldValue.isIncrement(zeroIncrement)).to.be.true; + }); + + it('does not match other sentinels or primitives', () => { + expect(FieldValue.isIncrement(serverTimestampSentinel)).to.be.false; + expect(FieldValue.isIncrement(arrayUnionSentinel)).to.be.false; + expect(FieldValue.isIncrement(arrayRemoveSentinel)).to.be.false; + expect(FieldValue.isIncrement(deleteSentinel)).to.be.false; + expect(FieldValue.isIncrement(5)).to.be.false; + expect(FieldValue.isIncrement(null)).to.be.false; + expect(FieldValue.isIncrement(undefined)).to.be.false; + expect(FieldValue.isIncrement({})).to.be.false; + }); + }); + + describe('isDecrement()', () => { + it('matches only increment sentinels with a negative operand', () => { + expect(FieldValue.isDecrement(negativeIncrement)).to.be.true; + expect(FieldValue.isDecrement(positiveIncrement)).to.be.false; + expect(FieldValue.isDecrement(zeroIncrement)).to.be.false; + }); + + it('is a subset of isIncrement()', () => { + expect(FieldValue.isDecrement(negativeIncrement)).to.be.true; + expect(FieldValue.isIncrement(negativeIncrement)).to.be.true; + }); + + it('does not match other sentinels or primitives', () => { + expect(FieldValue.isDecrement(serverTimestampSentinel)).to.be.false; + expect(FieldValue.isDecrement(arrayUnionSentinel)).to.be.false; + expect(FieldValue.isDecrement(arrayRemoveSentinel)).to.be.false; + expect(FieldValue.isDecrement(deleteSentinel)).to.be.false; + expect(FieldValue.isDecrement(-5)).to.be.false; + expect(FieldValue.isDecrement(null)).to.be.false; + expect(FieldValue.isDecrement(undefined)).to.be.false; + }); + }); + + describe('isArrayUnion()', () => { + it('matches only array-union sentinels', () => { + expect(FieldValue.isArrayUnion(arrayUnionSentinel)).to.be.true; + expect(FieldValue.isArrayUnion(arrayRemoveSentinel)).to.be.false; + expect(FieldValue.isArrayUnion(positiveIncrement)).to.be.false; + expect(FieldValue.isArrayUnion(serverTimestampSentinel)).to.be.false; + expect(FieldValue.isArrayUnion(deleteSentinel)).to.be.false; + }); + + it('returns false for non-sentinel values', () => { + expect(FieldValue.isArrayUnion(['foo'])).to.be.false; + expect(FieldValue.isArrayUnion(null)).to.be.false; + expect(FieldValue.isArrayUnion(undefined)).to.be.false; + expect(FieldValue.isArrayUnion({})).to.be.false; + }); + }); + + describe('isArrayRemove()', () => { + it('matches only array-remove sentinels', () => { + expect(FieldValue.isArrayRemove(arrayRemoveSentinel)).to.be.true; + expect(FieldValue.isArrayRemove(arrayUnionSentinel)).to.be.false; + expect(FieldValue.isArrayRemove(positiveIncrement)).to.be.false; + expect(FieldValue.isArrayRemove(serverTimestampSentinel)).to.be.false; + expect(FieldValue.isArrayRemove(deleteSentinel)).to.be.false; + }); + + it('returns false for non-sentinel values', () => { + expect(FieldValue.isArrayRemove(['foo'])).to.be.false; + expect(FieldValue.isArrayRemove(null)).to.be.false; + expect(FieldValue.isArrayRemove(undefined)).to.be.false; + expect(FieldValue.isArrayRemove({})).to.be.false; + }); + }); +}); diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 6626b3c5458f..fdf7a0cf2f3b 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -2775,6 +2775,50 @@ declare namespace FirebaseFirestore { * @returns A new `VectorValue` constructed with a copy of the given array of number. */ static vector(values?: number[]): VectorValue; + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.serverTimestamp}. + * + * @param value The value to check. + * @returns `true` if `value` is a server-timestamp sentinel. + */ + static isServerTimestamp(value: unknown): value is FieldValue; + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.increment}. This includes sentinels produced with a + * negative operand; use {@link FieldValue.isDecrement} to narrow further + * to decrement-style operations. + * + * @param value The value to check. + * @returns `true` if `value` is an increment sentinel. + */ + static isIncrement(value: unknown): value is FieldValue; + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.increment} with a negative operand. Every value that + * satisfies `isDecrement` also satisfies {@link FieldValue.isIncrement}. + * + * @param value The value to check. + * @returns `true` if `value` is an increment sentinel with a negative + * operand. + */ + static isDecrement(value: unknown): value is FieldValue; + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.arrayUnion}. + * + * @param value The value to check. + * @returns `true` if `value` is an array-union sentinel. + */ + static isArrayUnion(value: unknown): value is FieldValue; + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.arrayRemove}. + * + * @param value The value to check. + * @returns `true` if `value` is an array-remove sentinel. + */ + static isArrayRemove(value: unknown): value is FieldValue; /** * Returns true if this `FieldValue` is equal to the provided one. * From ca2cc578c1fa3c1d470764a4790cd38c8666aa7f Mon Sep 17 00:00:00 2001 From: kyungseopk1m Date: Thu, 23 Apr 2026 20:33:29 +0900 Subject: [PATCH 2/2] feat(firestore): add isDelete type guard Following the same pattern as the other four sentinel guards, expose a public `isDelete` static method so users can detect sentinels returned by `FieldValue.delete()` without relying on internal class names. Addresses review feedback on #8102. --- handwritten/firestore/dev/src/field-value.ts | 17 +++++++++++++++++ handwritten/firestore/dev/test/field-value.ts | 17 +++++++++++++++++ handwritten/firestore/types/firestore.d.ts | 8 ++++++++ 3 files changed, 42 insertions(+) diff --git a/handwritten/firestore/dev/src/field-value.ts b/handwritten/firestore/dev/src/field-value.ts index 0bd01cf6f66b..c8731da2200b 100644 --- a/handwritten/firestore/dev/src/field-value.ts +++ b/handwritten/firestore/dev/src/field-value.ts @@ -339,6 +339,23 @@ export class FieldValue implements firestore.FieldValue { return value instanceof ArrayRemoveTransform; } + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.delete}. + * + * @param value The value to check. + * @returns `true` if `value` is a delete sentinel. + * + * @example + * ``` + * Firestore.FieldValue.isDelete(Firestore.FieldValue.delete()); // true + * Firestore.FieldValue.isDelete(null); // false + * ``` + */ + static isDelete(value: unknown): value is FieldValue { + return value instanceof DeleteTransform; + } + /** * Returns true if this `FieldValue` is equal to the provided value. * diff --git a/handwritten/firestore/dev/test/field-value.ts b/handwritten/firestore/dev/test/field-value.ts index e089c5d0d4ed..858321ba4dab 100644 --- a/handwritten/firestore/dev/test/field-value.ts +++ b/handwritten/firestore/dev/test/field-value.ts @@ -383,4 +383,21 @@ describe('FieldValue sentinel type guards', () => { expect(FieldValue.isArrayRemove({})).to.be.false; }); }); + + describe('isDelete()', () => { + it('matches only delete sentinels', () => { + expect(FieldValue.isDelete(deleteSentinel)).to.be.true; + expect(FieldValue.isDelete(serverTimestampSentinel)).to.be.false; + expect(FieldValue.isDelete(positiveIncrement)).to.be.false; + expect(FieldValue.isDelete(arrayUnionSentinel)).to.be.false; + expect(FieldValue.isDelete(arrayRemoveSentinel)).to.be.false; + }); + + it('returns false for non-sentinel values', () => { + expect(FieldValue.isDelete(null)).to.be.false; + expect(FieldValue.isDelete(undefined)).to.be.false; + expect(FieldValue.isDelete('delete')).to.be.false; + expect(FieldValue.isDelete({})).to.be.false; + }); + }); }); diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index fdf7a0cf2f3b..3286f373ebd1 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -2819,6 +2819,14 @@ declare namespace FirebaseFirestore { * @returns `true` if `value` is an array-remove sentinel. */ static isArrayRemove(value: unknown): value is FieldValue; + /** + * Returns `true` if the provided value is a sentinel returned by + * {@link FieldValue.delete}. + * + * @param value The value to check. + * @returns `true` if `value` is a delete sentinel. + */ + static isDelete(value: unknown): value is FieldValue; /** * Returns true if this `FieldValue` is equal to the provided one. *