sinclairzx81 / typebox

Json Schema Type Builder with Static Type Resolution for TypeScript
Other
4.77k stars 152 forks source link

Usage of `Type.Composite` with `Type.TemplateLiteral` #939

Closed climba03003 closed 1 month ago

climba03003 commented 1 month ago

I knows that I can use Type.Intersect for the same effect. But using Type.Composite to generate single object schema provides better performance in fastify.

For example the below querystring, the types is incomplete and missing the patternProperties in result.

const whereClause = Type.TemplateLiteral([
  Type.Literal('where.'),
  Type.String()
])

const orderByClause = Type.TemplateLiteral([
  Type.Literal('orderby.'),
  Type.String()
])

const commonQueryString = Type.Composite([
  Type.Object({
    search: Type.Optional(Type.String()),
    fields: Type.Optional(Type.String()),
    page: Type.Optional(Type.Number({ minimum: 1 })),
    pageSize: Type.Optional(Type.Number({ minimum: 1 })),
  }),
  Type.Record(whereClause, Type.String()),
  Type.Record(orderByClause, Type.String()),
], {
  additionalProperties: false
})

Alternatives

It would be easier to directly support patternProperties through Type.Object in some way. So, it can eliminate the usages of Type.Composite.

sinclairzx81 commented 1 month ago

@climba03003 Hi! Apologies for the delay in reply (Been very busy of late)

I knows that I can use Type.Intersect for the same effect. But using Type.Composite to generate single object schema provides better performance in fastify. For example the below querystring, the types is incomplete and missing the patternProperties in result.

The missing properties in commonQueryString are largely a result of the following aspects.

Unfortunately, it's proven very difficult to integrate Record into Type.Composite compositions due to their nature (while having inference work correctly), which is also somewhat compounded by the options on TObject not contributing to inference results (which I plan to address in later revisions, but not quite sure the best way to proceed at this stage)

This said, the following should provide a reasonable workaround.

TypeScript Link Here

import { Type, Static, Evaluate } from '@sinclair/typebox'

export type CommonQueryString = Evaluate<Static<typeof CommonQueryString> 
  & Record<`where.${string}`, string>
  & Record<`orderby.${string}`, string>>

export const CommonQueryString = Type.Object({
  search: Type.Optional(Type.String()),
  fields: Type.Optional(Type.String()),
  page: Type.Optional(Type.Number({ minimum: 1 })),
  pageSize: Type.Optional(Type.Number({ minimum: 1 })),
}, {
  additionalProperties: false,
  patternProperties: {
    '^where.(.*)': Type.String(),
    '^orderby.(.*)': Type.String(),
  }
})

It would be easier to directly support patternProperties through Type.Object in some way. So, it can eliminate the usages of Type.Composite.

So, the above should do this. But just on Type.Composite. If you can avoid using this type (and preference using Intersect), it will be better in the long run. I am currently leaning towards the following future API as an alternative.

const A = Type.Object({ a: Type.Number() })
const B = Type.Object({ b: Type.Number() })
const I = Type.Intersect([A, B])

// Evaluate to replace Composite
const C = Type.Evaluate(I) // TObject<{ a: TNumber, b: TNumber }>

The introduction of the Evaluate function would operate similar to Composite, but is intended to emulate the Evaluate<T> type (in the example given). Unfortunately the complexity of this type has proven extremely difficult to get right (so is deferred for now). The general recommendation though is to try and use Intersect over Composite if you can, with the expectation that TypeBox may be able to optimize your schematics with Evaluate in future.

Hope this helps and brings a bit of insight into things All the best S

climba03003 commented 1 month ago

Actually, the types will go through another helper utility. So, it will use the actual const value and evaluate again which eliminate the override by type.

interface ExtractRouteGeneric<T extends Record<string, any>> {
  Params: Required<Static<T['params']>>
  Headers: Required<Static<T['headers']>>
  Querystring: Required<Static<T['querystring']>>
  Body: Required<Static<T['body']>>
}

Currently, I want to prevent Type.Intersect basically because of allOf.

For now, I just replace the generic with what I expect.

import { Type, Static, TOptional, TString, TNumber } from '@sinclair/typebox'

export type CommonQueryString = Static<typeof CommonQueryString>

export const CommonQueryString = Type.Object<{
  search: TOptional<TString>
  fields: TOptional<TString>
  page: TOptional<TNumber>
  pageSize: TOptional<TNumber>,
  // patternProperties
  [key: `where.${string}`]: TOptional<TString>
  [key: `orderby.${string}`]: TOptional<TString>
}>({
  search: Type.Optional(Type.String()),
  fields: Type.Optional(Type.String()),
  page: Type.Optional(Type.Number({ minimum: 1 })),
  pageSize: Type.Optional(Type.Number({ minimum: 1 })),
}, {
  additionalProperties: false,
  patternProperties: {
    '^where.(.*)': Type.String(),
    '^orderby.(.*)': Type.String(),
  }
})
sinclairzx81 commented 1 month ago

@climba03003 Heya

For now, I just replace the generic with what I expect.

Hmm, It might be possible to produce a utility type here. Below is a ObjectExtended utility type that will let you compose Object with Record types. This should remove the need for the explicit annotation and let you compose other object types.

TypeScript Link Here

import { Type, Static, TSchema, TUnsafe, TObject, TRecord, TOptional, TNumber, TString, Evaluate } from '@sinclair/typebox'

//-------------------------------------------------------------------
// ObjectExtended
//-------------------------------------------------------------------
export type ObjectExtended<T extends TSchema[], Acc extends unknown = unknown> = (
  T extends [infer L extends TSchema, ...infer R extends TSchema[]]
    ? ObjectExtended<R, Acc & Static<L>>
    : TUnsafe<Evaluate<Acc>>
)
function ObjectExtended<T extends TObject, R extends TRecord[]>(object: T, records: [...R]): ObjectExtended<[T, ...R]> {
  const patternProperties = records.reduce((acc, c) => ({...acc, ...c.patternProperties }), {})
  return { ...object, patternProperties } as never
}

//-------------------------------------------------------------------
// Usage
//-------------------------------------------------------------------

export type CommonQueryString = Static<typeof CommonQueryString>

const CommonQueryString = ObjectExtended(Type.Object({
  search: Type.Optional(Type.String()),
  fields: Type.Optional(Type.String()),
  page: Type.Optional(Type.Number({ minimum: 1 })),
  pageSize: Type.Optional(Type.Number({ minimum: 1 })),
}, { additionalProperties: false }), [
  Type.Record(Type.TemplateLiteral('where.${string}'), Type.String()),
  Type.Record(Type.TemplateLiteral('orderby.${string}'), Type.TemplateLiteral('${ascending|descending}')),
  Type.Record(Type.TemplateLiteral('groupby.${string}'), Type.String()),
])

It's a bit specific to extending Objects with Records (so couldn't include as part of TypeBox in it's current form), but should serve as a reasonable workaround if you need something a bit more flexible / generic.


Might close up this issue for now as there isn't much I can action in TypeBox today. But give the above a try and let me know how you go. Ill need to give compositions of this sort a review in future, but probably inline with Type.Evaluate (when/if lands) :)

All the best! S