#Having Fun with Generics

61 messages · Page 1 of 1 (latest)

wild onyx
#

How can I type a generic emitWithTimeout helper for a typed Root.Socket (with Root.Actions callbacks) so that:

  • It infers payload and response types from Root.Actions[K] (payload + callback or callback-only).
  • The call emitWithTimeout(socketRef, 'prompts:getPrompts', undefined, opts) gives a correctly typed { success; data; error? } response.
  • Without never showing up or needing extra manual SocketResponse<T> types?

Playground:
https://tinyurl.com/3z3jnu8u

warped lotus
#

the code is pretty difficult to understand, it might be more helpful to reduce your request to the smallest possible example.

i would guess that you can accomplish your goal using some combination of Parameters<Type> here and ReturnType<Type> here

wild onyx
# warped lotus the code is pretty difficult to understand, it might be more helpful to reduce y...
type Reference<T> = { value: T }

namespace Root {
  export interface Actions {
    'prompts:getPrompts': (
      handleResponse: (result: {
        success: boolean
        data: { id: number; name: string }[]
      }) => void
    ) => void
  }

  export interface Socket {
    emit: (...args: any[]) => void
  }
}

type ActionName = keyof Root.Actions

type LastParameter<FunctionType> =
  FunctionType extends (...args: [...any[], infer Last]) => any ? Last : never

type ActionCallback<ActionKey extends ActionName> =
  LastParameter<Root.Actions[ActionKey]>

type ActionResult<ActionKey extends ActionName> =
  ActionCallback<ActionKey> extends (result: infer ResultType) => void
    ? ResultType
    : never

function emitWithTimeout<ActionKey extends ActionName>(
  socketReference: Reference<Root.Socket>,
  actionName: ActionKey
): Promise<ActionResult<ActionKey>> {
  return new Promise(resolve => {
    const handleResponse = (result: ActionResult<ActionKey>) => resolve(result)
    socketReference.value.emit(actionName, handleResponse)
  })
}

// --- minimal usage ---

const socket: Root.Socket = {
  emit(eventName: string, handleResponse: (result: any) => void) {
    if (eventName === 'prompts:getPrompts') {
      handleResponse({
        success: true,
        data: [{ id: 1, name: 'Hello' }],
      })
    }
  },
}

const socketReference: Reference<Root.Socket> = { value: socket }

async function main() {
  const result = await emitWithTimeout(socketReference, 'prompts:getPrompts')
  // result: { success: boolean; data: { id: number; name: string }[] }
}
dapper smelt
wild onyx
dapper smelt
#

What kind of changes can you make then? Because I would've written this entire code very differently so that it's type safe, and avoids repetition. Right now emit: (...args: any[]) => void has basically zero safety.

wild onyx
#

I am not sure if it is typed exactly that way. I would need to post the exact type then.

#

I let the AI break it down to the minimum version.

#

But would make sense. That just represents the mocked Socket type of socket.io-client

#

With the zod validation layer encoded in the Actions

#
import type { Socket as SocketBase } from "socket.io-client";

export namespace Root {
    /** @desc The actual path of the Root namespace */
    export const path = "/";
    export interface Emission {
        "settings:updated": (p1: {
            settingKey: string;
            settingValue: unknown;
            dataType: string;
            updatedAt: string;
        }) => void;
        "settings:deleted": (p1: {
            settingKey: string;
        }) => void;
    }

     export interface Actions {
        "settings:getAll": (cb1: (p1: {
            success: boolean;
            data: {
                settingKey: string;
                settingValue: unknown;
                dataType: "string" | "number" | "boolean";
                updatedAt: string;
                required: boolean;
                minLength?: number | undefined;
                maxLength?: number | undefined;
                minValue?: number | undefined;
                maxValue?: number | undefined;
                description: string;
                category: string;
                encrypted: boolean;
            }[];
            error?: string | undefined;
        }) => void) => void;
    }

    export type SocketBase = Socket<Emission, Actions>;
}
dapper smelt
#

Best if you can put that into TS playground

#

TS playground supports import ... from 'npm-package' too.

wild onyx
#

i did put it into a playground?

#

but mb i forgot to validate the minimal example. there you can't see the behavior.

dapper smelt
wild onyx
#

cant work with that sry

dapper smelt
# wild onyx https://tinyurl.com/3z3jnu8u

Idk what this site is, but judging from that you have to mock things like Vue's ref/Ref<T>, it's probably better to use the official TS playground where you can just import { ref, Ref } from 'vue',

wild onyx
#

its my playground

#

it would flag me the vue import there too

dapper smelt
#

It works on the official TS playground. It takes a few seconds to load when you first import from an npm package because it needs to load from npm, but once it loads it works.

#

Mostly I'm saying it because you can actually import from socket.io-client and actually have type SocketBase = Socket<Emission, Actions> so I know what kind of things I can do with the actual socket type, than having to rely on your mocks which obviously aren't the same as the real types because it had emit: (...args: any[]) => void.

wild onyx
#

indeed

dapper smelt
#

Hmm, it's pretty problematic because socket.io-client uses Parameters<T> for emit which is very hostile to type manipulation.

#

I'll work on it a bit more later, but here's a playground reproduction if someone else wants to work on it:

unkempt kelpBOT
#
nonspicyburrito#0

Preview:```ts
import {Socket as SocketBase} from "socket.io-client"
import {Ref} from "vue"

type Emission = {}

type Actions = {
"settings:getAll": (
cb1: (
...```

wild onyx
#

ty burrito appreciated, ma playground will have the better package system soon hopefully.

dapper smelt
#

I don't think there's a fully safe way to do it, it must involve casting at some point, because socket.io-client uses Parameters<T>. If you had control of how socket.io-client worked, then it could've been made safe, but that's clearly not possible here.

#

Maybe someone else knows of a way, but I doubt it.

wild onyx
#

I am also open for workaround solutions

dapper smelt
#

Is socket.io-client a hard dependency that you cannot change the Socket type to use something else?

wild onyx
#

I could maybe try to do module augmentation

#

It is a dependency I require

dapper smelt
#

Well I wouldn't mess with socket.io-client's type, since your consumer might also want to use it directly too.

#

Here's how I would do it:

unkempt kelpBOT
#
nonspicyburrito#0

Preview:```ts
import {Socket as SocketBase} from "socket.io-client"
import {Ref} from "vue"

type ActionMap = {
"settings:getAll": {
success: boolean
data: {
settingKey: string
setti
...```

dapper smelt
#

This still involves a cast due to the Parameters<T> issue, but this should be the safest and most maintainable way to do it, since Actions is derived from ActionMap + ActionCallback, and the cast also uses ActionCallback.

wild onyx
#

would you recommend some specific ts docs pages i should look into to be able to understand your solution

#

generics are always turning me crazy. somehow i don't come further than beginner level there.

#

yeah, i digged into it and it seems to become clearer

#

i rly like your solution. thanks a bunch!

#

@dapper smelt

dapper smelt
#

Nice

#

Yeah socket.io-client basically takes your function (a, b, c) => void and turn it into (name, a, b, c) => void via Parameters<T>, which forces the cast to happen. It's unclear to me why they did that, because to me one parameter seems like it's more than enough, and it would've been better to just do [a, b, c] instead.

wild onyx
#

what, how does socket io client now name?

#

and it seems like module augmentation emit is pretty reasonable here instead of the cast

#

lol i could even monkey patch it into the socket client with module augmentation xD

#

like socket.emitWithTimeout()

#
declare module 'socket.io-client' {
  interface Socket<EmitEvents = any, SocketData = any> {
    emit<K extends ActionName>(
      ev: K,
      cb: ActionCallback<K>
    ): void

    // Monkey‑patched method
    emitWithTimeout<K extends ActionName>(
      actionName: K
    ): Promise<ActionMap[K]>
  }
}
#

That's pretty sick

#

oh that will be so nice. making my code much leaner.

#

maybe that violates p of cupid but idc

#

typed sockets is just lit as hell

dapper smelt
#

You would have to modify prototype to implement that, which is typically frowned upon.

wild onyx
#

yeah indeed makes it unnecessary overcomplex

wild onyx
#

!resolved

dapper smelt
#

Yeah.

wild onyx
#

is the callback the acknowledgement?

dapper smelt
#

What's acknowledgementM?