Skip to content

TypeScript type narrowing issues with TanStack Query hooks in v3.4 due to slicing ORM API introduction #2459

@niehaus1301

Description

@niehaus1301

Description

When using @zenstackhq/tanstack-query with the new slicing ORM API introduced in v3.4, there are two related TypeScript type issues:

Issue 1: Model accessors require non-null assertion

Model accessors returned from useClientQueries() are typed as potentially undefined, requiring non-null assertions (!) to use them even though they are always defined at runtime.

Issue 2: Procedure types cannot be narrowed

TypeScript cannot properly narrow the type when accessing procedures via $procs. The procedure type is a union of { useQuery... } and { useMutation... } based on whether the procedure has mutation: true, but TypeScript cannot discriminate between them at the access site.

Environment

  • ZenStack version: 3.4.4 (and related packages)
  • @zenstackhq/tanstack-query version: 3.4.4
  • TypeScript version: 5.x
  • Framework: Next.js
  • Package Manager: npm

Steps to Reproduce

Issue 1: Model accessors require non-null assertion

  1. Use useClientQueries from @zenstackhq/tanstack-query/react:
import { useClientQueries } from '@zenstackhq/tanstack-query/react';
import { schema } from '@repo/zenstack-schema/lite';

export function useZenstack() {
  return useClientQueries(schema);
}
  1. Try to access a model hook:
const zenstack = useZenstack();

// TypeScript Error: 'zenstack.client' is possibly 'undefined'
const { data } = zenstack.client.useFindMany({ where: { ... } });

// Workaround: requires non-null assertion
const { data } = zenstack.client!.useFindMany({ where: { ... } });

Issue 2: Procedure type narrowing

  1. Define procedures in your ZModel schema, some with mutation and some without:
// Query procedure (no mutation flag)
procedure getLatestCarloChat(dealershipId: String): CarloChat

// Mutation procedure
mutation procedure createCarloChat(dealershipId: String): CarloChat
  1. Use useClientQueries from @zenstackhq/tanstack-query/react:
import { useClientQueries } from '@zenstackhq/tanstack-query/react';
import { schema } from '@repo/zenstack-schema/lite';

export function useZenstack() {
  return useClientQueries(schema);
}
  1. Try to access a procedure:
const zenstack = useZenstack();

// TypeScript Error: Property 'useQuery' does not exist on type '...'
const { data } = zenstack.$procs.getLatestCarloChat.useQuery({
  args: { dealershipId: '...' }
});

Expected Behavior

Issue 1: Model accessors

Model accessors like zenstack.client, zenstack.vehicle, etc. should be typed as always defined (non-optional) since they are always present at runtime.

Issue 2: Procedure narrowing

TypeScript should be able to infer that getLatestCarloChat (without mutation: true) has useQuery and useSuspenseQuery methods, while createCarloChat (with mutation: true) has useMutation.

Actual Behavior

Issue 1: Model accessors

Model accessors are typed as T | undefined, requiring non-null assertions:

// Error without '!'
zenstack.client.useFindMany(...)

// Works with '!'
zenstack.client!.useFindMany(...)

Issue 2: Procedure narrowing

The type of zenstack.$procs.getLatestCarloChat is a union:

{ useQuery: ...; useSuspenseQuery: ... } | { useMutation: ... }

TypeScript cannot narrow this union because the discrimination happens at the mapped type level based on whether the procedure definition has mutation: true, but this information is lost when accessing the property dynamically.

Current Workaround

Issue 1: Model accessors

Use non-null assertion operator when accessing model hooks:

// Instead of: zenstack.client.useFindMany(...)
zenstack.client!.useFindMany(...)
zenstack.vehicle!.useCreate(...)

Issue 2: Procedures

We've had to use type assertions with helper types:

// Helper types
export type QueryProcedureHook<TReturn> = {
  useQuery: (
    input: { args: Record<string, unknown> },
    options?: { enabled?: boolean; gcTime?: number },
  ) => UseQueryResult<TReturn>;
  useSuspenseQuery: (
    input: { args: Record<string, unknown> },
    options?: Record<string, unknown>,
  ) => UseQueryResult<TReturn>;
};

export type MutationProcedureHook<TReturn, TArgs = Record<string, unknown>> = {
  useMutation: (options?: {
    onSuccess?: (data: TReturn) => void;
    onError?: (error: Error) => void;
  }) => UseMutationResult<TReturn, Error, { args: TArgs }>;
};

// Usage
const proc = zenstack.$procs!.getLatestCarloChat as unknown as QueryProcedureHook<CarloChat>;
const { data } = proc.useQuery({ args: { dealershipId } });

Suggested Solutions

Issue 1: Model accessors

  • Make model accessors non-optional: The generated type should not include | undefined for model accessors since they are always present at runtime when the schema includes the model.

Issue 2: Procedure narrowing

  1. Generate discriminated procedure types: Instead of a single mapped type for all procedures, generate separate typed accessors for query vs mutation procedures.

  2. Use branded types or type predicates: Add runtime-checkable discriminators that TypeScript can use for narrowing.

  3. Split $procs into $queries and $mutations: This would allow TypeScript to infer the correct type without needing discrimination.

  4. Generate individual procedure hooks: Instead of a single $procs object, generate named exports for each procedure with the correct type.

Related

Additional Context

These issues affect a significant portion of our codebase. The workarounds work but:

  • Issue 1: Requires adding ! assertions throughout the codebase which is error-prone and verbose
  • Issue 2: Type assertions lose type safety for procedure arguments and return types

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions