sinclairzx81 / typebox

Json Schema Type Builder with Static Type Resolution for TypeScript
Other
4.98k stars 157 forks source link

Unable to create a factory function that outputs instantiations of a generic class #453

Closed stuft2 closed 1 year ago

stuft2 commented 1 year ago

I'm trying to create a factory function which outputs various instantiations of a generic class. Here's a basic, contrived example:

class Construct<T = unknown> {
  constructor (readonly payload: T, readonly description?: string) {}
}

function createConstructFactory<T extends TSchema> (constructDataSchema: T) {
  const schema = Type.Object({
    description: Type.String(),
    payload: constructDataSchema
  })
  const check = TypeCompiler.Compile(schema)

  return (data: unknown) => {
    if (!check.Check(data)) {
      throw AggregateError(check.Errors(data))
    }
    return new Construct(data.payload, data.description)
//                            ^^^^^^^       ^^^^^^^^^^^
// Property 'payload' does not exist on type 'Evaluate   { description: string; payload: Static ; }, T extends TReadonlyOptional  ? "payload" : never>>> & Readonly...> & Partial...> & Required...>>'
// Property 'description' does not exist on type 'Evaluate   { description: string; payload: Static ; }, T extends TReadonlyOptional  ? "payload" : never>>> & Readonly...> & Partial...> & Required...>>'.
  }
}

I'm not sure why the error occurs exactly and I can't find any work arounds. Is this a bug or is it a feature that Typebox can't support yet do to Typescript limitations?

sinclairzx81 commented 1 year ago

@stuft2 Hi, this is an interesting one.

I think this could very well be a limitation in TypeScript where it can't quite resolve the static type through control flow analysis (where most of the type information in the example given is derived mostly through control flow), but it's an unusual one....

The issue appears to stem from the constrained generic argument T extends TSchema not being observed as a TSchema through control flow. My guess is that TypeScript makes the assessment that T can be variant (and expects covariant types of TSchema), but when it tries to instantiate guard return types (i.e. TypeCheck<T extends TSchema>.Check(value: unknown): value is Static<T>), it's can't make a call on the guards return signature given the potential for variance (and doesn't just default to TSchema (which would infer as unknown and would be expected (at least I think so))).

Workaround

Because the issue appears to relate to the constrained generic type, control flow inference of that type, and inference issues instantiating multiple possible types of TSchema in the guard (there's quite a bit going on here), you can side-step TypeScript's handling of TSchema (as a generic) and just use it as a non-generic type. This will be enough to get the behavior you want at runtime, but you will need to be explicit about the return type.

return new Construct<Static<T>>(data.payload, data.description)

Quick example below of how you can approach this.

TypeScript Link Here

import {Type, Static, TSchema } from '@sinclair/typebox'
import { TypeCompiler } from '@sinclair/typebox/compiler'

class Construct<T = unknown> {
  constructor (readonly payload: T, readonly description?: string) {}
}

// the schema is comprised of description and payload of non-generic TSchema
const createConstructSchema = (schema: TSchema) => Type.Object({
    description: Type.String(),
    payload: schema 
})

// note: generic argument T is only used to infer the `Construct<Static<T>>` return type
function createConstructFactory<T extends TSchema> (constructDataSchema: T) {
  const schema = createConstructSchema(constructDataSchema)
  const check = TypeCompiler.Compile(schema) 
  return (data: unknown) => {
    if (!check.Check(data)) {
      throw 1
    }
    return new Construct<Static<T>>(data.payload, data.description)
  }
}

const s = createConstructFactory(Type.Object({ x: Type.Number(), y: Type.Number() }))
//    ^?

I might do a bit more digging here, but it's unlikely TypeBox can do much to help TS resolve things here (I've observed quite a few unusual rules with is and assert guards as they relate to control flow and generics). However when working with variant types of TSchema, it can be helpful sometimes to operate in a "non-generic" way, but use TS to nudge the signatures you want from functions.

Hope this helps! S

sinclairzx81 commented 1 year ago

@stuft2 Hi, might close this one off as there's likely not much TB can do to resolve this. The example provided should get around the issue however (explicitly treating the schema as TSchema rather than relying on TS generics + control flow to derive the TSchema). But it is a curious behavior (likely tied to TS covariance checks, as a guess), but as for resolving the issue through control flow (as per your implementation), I'm not sure this is possible (at least with the current TB setup)

Happy to discuss more on this thread (there's probably a pattern to document here around this particular limitation), so if you have follow on thoughts, let me know. But yeah, will close for now as there isn't much to action at this time.

All the best! S

stuft2 commented 1 year ago

I'm still experimenting with this kind of implementation. I'll try your suggestion and share anything I learn relevant to the conversation.