Skip to content

Optionally use strong type hierarchies for read outputs and write inputs.#50

Merged
pkaminski merged 1 commit intomasterfrom
strong-types
Mar 20, 2026
Merged

Optionally use strong type hierarchies for read outputs and write inputs.#50
pkaminski merged 1 commit intomasterfrom
strong-types

Conversation

@pkaminski
Copy link
Member

@pkaminski pkaminski commented Mar 11, 2026

This change is Reviewable

@pkaminski pkaminski requested a review from snoack March 11, 2026 06:42
@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly upgrades the TypeScript typing capabilities of the NodeFire library. By introducing distinct type parameters for read and write operations, it allows developers to define precise data shapes for both retrieving and storing data, including special handling for Firebase-specific write patterns like server timestamps and increments. This change enhances the overall robustness and maintainability of applications built with NodeFire by catching type-related errors at compile time.

Highlights

  • Enhanced Type Safety for NodeFire: Introduced generic type parameters (Root, WriteSpecialRules, WriteRoot) to the NodeFire class, enabling strong typing for both read outputs and write inputs, significantly improving type safety and developer experience.
  • Advanced TypeScript Utility Types: Implemented a suite of new utility types (WriteShape, ResultFor, UpdateShape, PushValue, WriteSpecialRule, etc.) to support complex type inference for database paths and write operations, including handling Firebase server values and null for deletions.
  • Updated Method Signatures: Modified method signatures across the NodeFire and Snapshot classes to leverage the new type parameters, providing more accurate type checking for operations like get, set, update, push, transaction, on, off, child, ref, root, parent, and scope.
  • Documentation and Version Update: Added comprehensive documentation in README.md explaining the new TypeScript write placeholders feature and updated the package.json version to 4.1.0 to reflect these significant typing improvements.
Changelog
  • README.md
    • Added a new section detailing TypeScript write placeholders and their usage with NodeFire.
  • package.json
    • Updated the package version from 4.0.0 to 4.1.0.
  • src/nodefire.ts
    • Removed redundant Value and StoredValue type definitions.
    • Introduced generic type parameters Root, WriteSpecialRules, and WriteRoot to the NodeFire class.
    • Updated return types for ref, root, parent, and scope getters to propagate NodeFire's generic types.
    • Added overloads and updated return types for child and childRaw methods to provide path-specific type inference.
    • Changed the return type of the get method from Promise<StoredValue> to Promise<Root>.
    • Modified the set method to include overloads for typed WriteRoot values and unchecked unknown values.
    • Updated the parameter type of the update method to UpdateShape<WriteRoot>.
    • Changed the parameter type of the push method to PushValue<WriteRoot> | null.
    • Adjusted the transaction method's updateFunction parameter and return types to use Root and WriteRoot.
    • Updated the on and off callback parameter types to use Snapshot<Root>.
    • Modified the Snapshot class to be generic (Snapshot<Root>) and updated its val(), child(), and forEach() method signatures accordingly.
    • Introduced generic type parameter T to CapturableCallback, captureCallback, and popCallback functions.
    • Genericized getNormalValue and getNormalRawValue functions with type parameter T for more accurate type handling.
    • Added numerous new TypeScript utility types (Split, NormSeg, NormalizeParts, Resolve, DataTypeFrom, PathError, JoinParts, BrandedLookup, ResultFor, WriteSpecialRule, RuleSpecialForKey, NoUndefined, WriteShape, PushValue, StrictPartial, UpdateShape) to enable the new strong typing features.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a significant improvement by adding strong typing for read and write operations using TypeScript generics. The use of advanced conditional types for path resolution and for deriving write shapes from read shapes is well-executed and will greatly improve the developer experience.

The changes are consistently applied across the NodeFire and Snapshot classes, propagating the new type information through methods like child, ref, parent, etc.

I have one critical point of feedback regarding the getNormalRawValue function, where an incorrect type cast can lead to runtime errors when dealing with arrays in data structures. Please see the specific comment for details.

Overall, this is a fantastic enhancement to the library's type safety.

src/nodefire.ts Outdated
Comment on lines +910 to +918
function getNormalRawValue<T>(value: T): T {
if (_.isArray(value)) {
const normalValue = {};
_.forEach(value, (item, key) => {
if (!_.isNil(item)) {
normalValue[key] = getNormalRawValue(item);
}
});
value = normalValue;
value = normalValue as T;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The type signature for getNormalRawValue is <T>(value: T): T, but the implementation can return a different type than it received. If value is an array, it is converted to an object, but then cast back to T with value = normalValue as T; on line 918. This can lead to unexpected runtime errors if the caller expects an array.

For example:

interface MyData {
  items: string[];
}
const ref = new NodeFire<MyData>(...);
const data = await ref.get(); // data is typed as MyData
// data.items is typed as string[] but is actually an object `{'0': 'a', '1': 'b'}` at runtime.
data.items.map(i => ...); // This will throw a runtime error: data.items.map is not a function

This type cast is unsafe and subverts the type system. Since the array-to-object conversion is a documented feature of nodefire, the type signature should reflect this transformation to prevent runtime errors. You could achieve this with a Normalized<T> utility type that maps array types to object types.

A less ideal but simple fix would be to change the return type to any to signal the type is not guaranteed, but this would sacrifice the benefits of the new typings.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9093bc68bb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

src/nodefire.ts Outdated
* error. The value returned is normalized, meaning arrays are converted to objects.
*/
get(options?: {timeout?: number, cache?: boolean}): Promise<StoredValue> {
get(options?: {timeout?: number, cache?: boolean}): Promise<Root> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make get() nullable for missing Firebase nodes

NodeFire.get is now typed as Promise<Root>, but Firebase returns null when the target path does not exist. In typed projects where Root is non-nullable (the common case), this creates a false non-null guarantee and can lead to runtime crashes after dereferencing the result from empty paths. The return type should include null (or Root should consistently encode nullability) to match actual DataSnapshot.val() semantics.

Useful? React with 👍 / 👎.

src/nodefire.ts Outdated
}

val(): Value {
val(): Root {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Type Snapshot.val() as nullable for absent data

Snapshot.val() is declared to return Root, but callback snapshots can represent missing/deleted nodes and val() returns null in those cases. This again gives callers a non-null type that is not true at runtime, so listener code can compile and then fail when a node is removed or not yet created. The method’s return type should include null to preserve soundness.

Useful? React with 👍 / 👎.

Copy link
Member Author

@pkaminski pkaminski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pkaminski resolved 3 discussions.
Reviewable status: 0 of 3 files reviewed, all discussions resolved (waiting on snoack).

Copy link
Contributor

@snoack snoack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snoack reviewed 1 file and made 5 comments.
Reviewable status: 1 of 3 files reviewed, 5 unresolved discussions (waiting on pkaminski).


src/nodefire.ts line 251 at r2 (raw file):

  child<const Paths extends readonly string[]>(
    path: string, scope: Scope, typePaths: Paths

What is going on here? So typePaths is unused in the implementation, what is the point of having it in this overload then? If it is just so that calling code can signal the type, can't it do so by passing it via the generic, i.e. child<Type_Goes_Here>(...)?


src/nodefire.ts line 265 at r2 (raw file):

  /**
   * Creates a new NodeFire object on a child of this one, without interpolating the path.  Useful
  * when the path may contain interpolation syntax that must be disregarded, and you've already

Looks like you accidentally removed a space here, misaligning the JSDoc comment.


src/nodefire.ts line 420 at r2 (raw file):

      timeout?: number
    }
  ): Promise<ReadValue<Root> | null | undefined> {

Previously, the type returned by the updateFunction was the same generic returned by transaction itself.


src/nodefire.ts line 1026 at r2 (raw file):

type PathError<Message extends string> = {
  readonly [PATH_ERROR_BRAND]: Message;
} & never;

& never collapses to never, so this is essentially quivalent to type PathError = never.


src/nodefire.ts line 1095 at r2 (raw file):

type UpdateShape<T> =
  StrictPartial<T> & {[path: string]: unknown | null};

unknown | null collapses to unknown.

Copy link
Member Author

@pkaminski pkaminski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pkaminski made 5 comments.
Reviewable status: 1 of 3 files reviewed, 5 unresolved discussions (waiting on snoack).


src/nodefire.ts line 251 at r2 (raw file):

Previously, snoack (Sebastian Noack) wrote…

What is going on here? So typePaths is unused in the implementation, what is the point of having it in this overload then? If it is just so that calling code can signal the type, can't it do so by passing it via the generic, i.e. child<Type_Goes_Here>(...)?

This seemed like the most convenient way to be able to specify that ref will point to one of a set of paths. Could probably also do it by specifying the generic type directly but that's less readable IMO, especially as these will usually be multi-line lists.


src/nodefire.ts line 265 at r2 (raw file):

Previously, snoack (Sebastian Noack) wrote…

Looks like you accidentally removed a space here, misaligning the JSDoc comment.

Done.


src/nodefire.ts line 420 at r2 (raw file):

Previously, snoack (Sebastian Noack) wrote…

Previously, the type returned by the updateFunction was the same generic returned by transaction itself.

We didn't distinguish between read and write types before; I believe the new types are correct. The callback returns a write value (e.g., one with SERVER_TIMESTAMP), that the transaction then posts to Firebase, which in turn returns a snapshot of the written value with any placeholders resolved (i.e., a read value).


src/nodefire.ts line 1026 at r2 (raw file):

Previously, snoack (Sebastian Noack) wrote…

& never collapses to never, so this is essentially quivalent to type PathError = never.

Indeed, Codex messed up and I didn't know enough to stop it. Should be fixed now, and it revealed a bunch of extra errors in the server as a bonus.


src/nodefire.ts line 1095 at r2 (raw file):

Previously, snoack (Sebastian Noack) wrote…

unknown | null collapses to unknown.

Right, that was a type bug. Fixed.

Copy link
Contributor

@snoack snoack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snoack made 3 comments and resolved 2 discussions.
Reviewable status: 0 of 3 files reviewed, 3 unresolved discussions (waiting on pkaminski).


src/nodefire.ts line 251 at r2 (raw file):

Previously, pkaminski (Piotr Kaminski) wrote…

This seemed like the most convenient way to be able to specify that ref will point to one of a set of paths. Could probably also do it by specifying the generic type directly but that's less readable IMO, especially as these will usually be multi-line lists.

Isn't this specifically what the generic type syntax is for?

Looking at a real world example:

const enrollmentRef = db.child(
  'users/{subtask.userKey}/enrollments/{subtask.type}',
  scope,
  ['users/$/enrollments/public', 'users/$/enrollments/private', 'users/$/enrollments/personal']
);

... would become ...

  const enrollmentRef = db.child<[
    'users/$/enrollments/public',
    'users/$/enrollments/private',
    'users/$/enrollments/personal'
  ]>('users/{subtask.userKey}/enrollments/{subtask.type}', scope);

I don't think that is any less readable.


src/nodefire.ts line 420 at r2 (raw file):

Previously, pkaminski (Piotr Kaminski) wrote…

We didn't distinguish between read and write types before; I believe the new types are correct. The callback returns a write value (e.g., one with SERVER_TIMESTAMP), that the transaction then posts to Firebase, which in turn returns a snapshot of the written value with any placeholders resolved (i.e., a read value).

But don't we lose inferability of the return value based on what the updateFunction returns? If the return type of updateFunction is lets say Foo, Promise<Foo> was inferred as return type of transaction before. I suppose there are edge cases like SERVER_TIMESTAMP though.


src/nodefire.ts line 1095 at r2 (raw file):

Previously, pkaminski (Piotr Kaminski) wrote…

Right, that was a type bug. Fixed.

{} | null collapses to {}. I'm not sure if it's possible to express in TypeScript "anything but undefined".

BTW, {} is equivalent to NonNullable<unknown> which you could use instead of silencing the ESLint error. But since you want to allow null here, you might have to use any.

Copy link
Member Author

@pkaminski pkaminski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pkaminski made 3 comments.
Reviewable status: 0 of 3 files reviewed, 3 unresolved discussions (waiting on snoack).


src/nodefire.ts line 251 at r2 (raw file):

Previously, snoack (Sebastian Noack) wrote…

Isn't this specifically what the generic type syntax is for?

Looking at a real world example:

const enrollmentRef = db.child(
  'users/{subtask.userKey}/enrollments/{subtask.type}',
  scope,
  ['users/$/enrollments/public', 'users/$/enrollments/private', 'users/$/enrollments/personal']
);

... would become ...

  const enrollmentRef = db.child<[
    'users/$/enrollments/public',
    'users/$/enrollments/private',
    'users/$/enrollments/personal'
  ]>('users/{subtask.userKey}/enrollments/{subtask.type}', scope);

I don't think that is any less readable.

I'm not a fan of of separating a function's arguments from the function name by a few lines of type clutter. The downside of my approach is that it's not clear whether the third argument is actually used for anything. Dunno, feels like 6 of one kind half dozen of the other; the only good thing is that this form is used super rarely so shouldn't matter much in the end.


src/nodefire.ts line 420 at r2 (raw file):

Previously, snoack (Sebastian Noack) wrote…

But don't we lose inferability of the return value based on what the updateFunction returns? If the return type of updateFunction is lets say Foo, Promise<Foo> was inferred as return type of transaction before. I suppose there are edge cases like SERVER_TIMESTAMP though.

The return value of transaction doesn't depend on what updateFunction returns; if the previous types implied this then they were wrong. transaction always returns the value of a snapshot at the given location (unless the transaction was aborted with undefined).


src/nodefire.ts line 1095 at r2 (raw file):

Previously, snoack (Sebastian Noack) wrote…

{} | null collapses to {}. I'm not sure if it's possible to express in TypeScript "anything but undefined".

BTW, {} is equivalent to NonNullable<unknown> which you could use instead of silencing the ESLint error. But since you want to allow null here, you might have to use any.

Apparently {} | null doesn't collapse to {} with strictNullChecks: true turned on, which it is for all our projects.

I think {} communicates intent better than NonNullable<unknown> and suppressing the linter further clarifies that this type is very much intended.

Copy link
Contributor

@snoack snoack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snoack made 1 comment and resolved 2 discussions.
Reviewable status: 0 of 3 files reviewed, 1 unresolved discussion (waiting on pkaminski).


src/nodefire.ts line 251 at r2 (raw file):

Previously, pkaminski (Piotr Kaminski) wrote…

I'm not a fan of of separating a function's arguments from the function name by a few lines of type clutter. The downside of my approach is that it's not clear whether the third argument is actually used for anything. Dunno, feels like 6 of one kind half dozen of the other; the only good thing is that this form is used super rarely so shouldn't matter much in the end.

If the concern is having too much boilerplate right after the function name, you can use a temporary type definition:

type EnrollmentPaths = ['users/$/enrollments/public', 'users/$/enrollments/private', 'users/$/enrollments/personal'];
const enrollmentRef = db.child<EnrollmentPaths>('users/{subtask.userKey}/enrollments/{subtask.type}', scope);

Also the type wouldn't need to be an array to start with, and then it could be expressed more concise as template literal type:

type EnrollmentPath = `users/$/enrollments/${'public'|'private'|'personal'}`;

The benefits I'm seeing here is:

  1. This is exactly what generics syntax is intended for in TypeScript, and adding a dummy argument just to have the type inferred feels like we are working against TypeScript.
  2. It makes it undoubtedly clear that these information are unused at run time.
  3. It keeps the generated JavaScript code small.
  4. The type does not need to be an array, but can leverage type unions and template literal types.

Copy link
Member Author

@pkaminski pkaminski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pkaminski made 1 comment.
Reviewable status: 0 of 3 files reviewed, 1 unresolved discussion (waiting on snoack).


src/nodefire.ts line 251 at r2 (raw file):

Previously, snoack (Sebastian Noack) wrote…

If the concern is having too much boilerplate right after the function name, you can use a temporary type definition:

type EnrollmentPaths = ['users/$/enrollments/public', 'users/$/enrollments/private', 'users/$/enrollments/personal'];
const enrollmentRef = db.child<EnrollmentPaths>('users/{subtask.userKey}/enrollments/{subtask.type}', scope);

Also the type wouldn't need to be an array to start with, and then it could be expressed more concise as template literal type:

type EnrollmentPath = `users/$/enrollments/${'public'|'private'|'personal'}`;

The benefits I'm seeing here is:

  1. This is exactly what generics syntax is intended for in TypeScript, and adding a dummy argument just to have the type inferred feels like we are working against TypeScript.
  2. It makes it undoubtedly clear that these information are unused at run time.
  3. It keeps the generated JavaScript code small.
  4. The type does not need to be an array, but can leverage type unions and template literal types.

Brilliant, done!

Copy link
Contributor

@snoack snoack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:lgtm:

@snoack reviewed 3 files and all commit messages, made 1 comment, and resolved 1 discussion.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on pkaminski).

@pkaminski pkaminski merged commit a53a067 into master Mar 20, 2026
4 checks passed
@pkaminski pkaminski deleted the strong-types branch March 20, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants