Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Improvement] Add generic error types to useQuery of @powersync/react #472

Open
guillempuche opened this issue Jan 16, 2025 · 1 comment

Comments

@guillempuche
Copy link
Contributor

Problem

Currently, the PowerSync query system uses a generic Error type for error handling in QueryResult https://github.com/powersync-ja/powersync-js/blob/main/packages/react/src/hooks/useQuery.ts#L9 and https://github.com/powersync-ja/powersync-js/blob/main/packages/common/src/types/types.ts.

This makes it difficult to handle specific types of errors in a type-safe manner when using TypeScript, forcing developers to use type assertions or broadly catch all errors without type discrimination.

Proposed Solution

Add generic error type support to the query system, allowing developers to specify and handle specific error types with full TypeScript support.

Type Changes

// In packages/common/src/types/types.ts
export interface CompilableQuery<T, E = Error> {
  execute(): Promise<T[]>;
  compile(): CompiledQuery;
}
// In packages/react/src/hooks/useQuery.ts
export type QueryResult<T, E = Error> = {
  data: T[];
  isLoading: boolean;
  isFetching: boolean;
  error: E | undefined;
  refresh?: () => Promise<void>;
};

export const useQuery = <T = any, E = Error>(
  query: string | CompilableQuery<T, E>,
  parameters?: any[],
  options?: AdditionalOptions
): QueryResult<T, E> => {
  // Implementation remains largely the same
  // Just needs to properly type the error handling
};

Usage Example

// Define your error types
interface PowerSyncError {
  type: string;
  message: string;
}

interface SQLError extends PowerSyncError {
  type: 'SQLError';
  code: number;
}

interface ValidationError extends PowerSyncError {
  type: 'ValidationError';
  fields: string[];
}

type AppError = SQLError | ValidationError;

// In your React component
function MyComponent() {
  const { data, error } = useQuery<User, AppError>('SELECT * FROM users');

  if (error) {
    if (error.type === 'SQLError') {
      // TypeScript knows this is SQLError
      console.error(`Database error ${error.code}: ${error.message}`);
    } else if (error.type === 'ValidationError') {
      // TypeScript knows this is ValidationError
      console.error(`Invalid fields: ${error.fields.join(', ')}`);
    }
  }

  return // ...
}

Benefits

  1. Type-safe error handling
  2. Better developer experience with IDE support
  3. Clearer error handling patterns
  4. No breaking changes for existing code (defaults to current Error type)
  5. Enables custom error types for different use cases

Implementation Notes

  1. This change is backward compatible as it defaults to the current Error type
  2. Minimal changes required to existing codebase
@guillempuche
Copy link
Contributor Author

guillempuche commented Jan 23, 2025

This is a case that I'm applying to my app:

// ----- Entities
import { Schema } from 'effect'
export class Quote extends Schema.Class<Quote>('@repo/context-domain/Quote')({
    text:: Schema.String
    ...
}

// ----- Queries
const make = Effect.map(SqliteDb, kysely => ({
    ...
	getAllMine: (profileId: string): CompilableGetter<Quote> => {
		const query = kysely
			.selectFrom('quotes')
			.selectAll()
			.where('createdByProfile', '=', profileId)
			.orderBy('createdAt', 'desc')

		return {
			execute: async () =>
				Effect.runPromise(
					Effect.gen(function* () {
						const dbRows = yield* Effect.tryPromise({
							try: () => query.execute(),
							catch: error => new ErrorSqliteQuery({ error }),
						})

						return yield* Effect.all(
							dbRows.map(_ =>
								Schema.decodeUnknown(Quote)(_).pipe(
									Effect.mapError(error => new ErrorRepoRowToSchema({ error })),
								),
							),
						)
					}),
				),
			compile: () => query.compile(),
		}
	},
    ...
}))
export class RepoQuote extends Effect.Tag('repos-sqlite/RepoQuote')<
	RepoQuote,
	Effect.Effect.Success<typeof make>
>() {
	static readonly Live = Layer.effect(this, make).pipe(
		Layer.provide(SqliteDb.Live),
	)
}

// ------ Errors
import { Data } from 'effect'
export class ErrorSqliteQuery extends Data.TaggedError('ErrorSqliteQuery')<{
	readonly error?: any
}> {}
export class ErrorRepoRowToSchema extends Data.TaggedError(
	'ErrorRepoRowToSchema',
)<{
	readonly error?: any
}> {}

// ----- For running Effect logic
import { ManagedRuntime, Layer } from 'effect'
const ReposLive = Layer.mergeAll(
	RepoQuote.Live,
).pipe(Layer.provide(PowerSyncClientLive))
const MainLayer = Layer.mergeAll(
	PowerSyncClientLive,
	PowerSyncConnectorLive,
	ReposLive,
).pipe(Layer.provide(Layer.provideMerge(EnvConfigProviderLayer, LoggingLive)))
export const RuntimeClient = ManagedRuntime.make(MainLayer)

----- The React component:

    // getAllMine: (profileId: string) => Effect.Effect<CompilableGetter<Quote>, never, RepoQuote>
	const { data, error } = useQuery<Quote>(
		RuntimeClient.runSync(RepoQuote.getAllMine(profileId)),
	)

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

No branches or pull requests

1 participant