electric-sql / electric

Sync little subsets of your Postgres data into local apps and services.
https://electric-sql.com
Apache License 2.0
6.52k stars 156 forks source link

How to use the `Extensions` Row type machinery when using a type with a custom parser. #2080

Closed thruflo closed 7 hours ago

thruflo commented 2 days ago

I have a type like this:

type Todo = {
  id: string
  title: string
  completed: boolean
  created_at: Date
}

The created_at field is a Date column, stored as a timestamptz field in Postgres. This is not one of the types supported by the default parser, so I use a custom parser with my ShapeStreamOptions:

const stream = new ShapeStream<Todo>({
  url: `${ ELECTRIC_URL }/v1/shape`,
  table: 'todos',
  parser: {
    timestamptz: (value: string) => new Date(value)
  }
})

I later call stream.subscribe with a callback function and get a type error. Just to give the full context, I call stream.subscribe as part of this matchStream function:

export function matchStream<T extends Row>(
  stream: ShapeStreamInterface<T>,
  operations: Array<Operation>,
  matchFn: (message: ChangeMessage<T>) => boolean,
  timeout = 60000 // ms
): Promise<ChangeMessage<T>> {
  return new Promise<ChangeMessage<T>>((resolve, reject) => {
    const unsubscribe: () => void = stream.subscribe((messages) => {
      const message = messages.filter(isChangeMessage).find((message) => {
        const operation = message.headers.operation

        return operations.includes(operation) && matchFn(message)
      })
      // ...

E.g. by calling:

matchStream(stream, ['delete'], matchBy('id', id))

This raises a type error:

patterns/2-optimistic-state/index.tsx:156:9 - error TS2345: Argument of type 'ShapeStream<Todo>' is not assignable to parameter of type 'ShapeStreamInterface<Row<never>>'.
  Types of property 'subscribe' are incompatible.
    Type '(callback: (messages: Message<Todo>[]) => MaybePromise<void>, onError?: ((error: FetchError | Error) => void) | undefined) => () => void' is not assignable to type '(callback: (messages: Message<Row<never>>[]) => MaybePromise<void>, onError?: ((error: FetchError | Error) => void) | undefined) => () => void'.
      Types of parameters 'callback' and 'callback' are incompatible.
        Types of parameters 'messages' and 'messages' are incompatible.
          Type 'Message<Todo>[]' is not assignable to type 'Message<Row<never>>[]'.
            Type 'Message<Todo>' is not assignable to type 'Message<Row<never>>'.
              Type 'ChangeMessage<Todo>' is not assignable to type 'Message<Row<never>>'.
                Type 'ChangeMessage<Todo>' is not assignable to type 'ChangeMessage<Row<never>>'.
                  Type 'Todo' is not assignable to type 'Row<never>'.
                    Property 'created_at' is incompatible with index signature.
                      Type 'Date' is not assignable to type 'Value<never>'.
                        Type 'Date' is not assignable to type '{ [key: string]: Value<never>; }'.
                          Index signature for type 'string' is missing in type 'Date'.

Now, I see there's some clever type machinery in the Row type definition in the Typescript client in src/types.ts designed to support types using a custom parser:

/**
 * Default types for SQL but can be extended with additional types when using a custom parser.
 * @typeParam Extensions - Additional value types.
 */
export type Value<Extensions = never> =
  | string
  | number
  | boolean
  | bigint
  | null
  | Extensions
  | Value<Extensions>[]
  | { [key: string]: Value<Extensions> }

export type Row<Extensions = never> = Record<string, Value<Extensions>>

export type GetExtensions<T extends Row<unknown>> =
  T extends Row<infer Extensions> ? Extensions : never

How do I use this when defining my Todo type to provide the information about the created_at field's Date type?

kevin-dp commented 1 day ago

@thruflo I explained usage of never vs unknown in this context here: https://github.com/electric-sql/electric/pull/1791#issuecomment-2396160005

We use never as the default when we want to ensure that if no type argument is passed, we treat it as only the base types with no extensions. And we use unknown whenever we want to allow the user to pass in any extended type.

If we look at your snippet:

export function matchStream<T extends Row>

The problem is that T is said to extend Row. Since you don't pass any type argument to Row it uses the default of never, so in fact we have:

export function matchStream<T extends Row<never>>

Thus, we require T to extend Row and we don't allow any extensions since the extensions are set to never. But, matchStream(stream, ['delete'], matchBy('id', id)) is calling it with Todo as a type argument which does define an extension. Hence, the type mismatch.

To fix this problem, you need to modify your definition of matchStream to allow unknown extensions:

export function matchStream<T extends Row<unknown>>

Now, we're saying that T can be a row type and may include extensions (whose type we actually don't know).

thruflo commented 1 day ago

Brilliant, thanks for the lesson :)

Out of interest, I see GetExtensions used in the type signatures of a few of the react-hooks functions, like getShapeStream. Is there any scenario where I might use it directly?

kevin-dp commented 1 day ago

Out of interest, I see GetExtensions used in the type signatures of a few of the react-hooks functions, like getShapeStream. Is there any scenario where I might use it directly?

You can use it whenever you need the type of the extensions but you only have the entire Row type. So, GetExtensions<Row<Todo>> = Todo but you would use it when you need to get the extensions type from some type T where T extends Row<unknown>.