sinclairzx81 / typebox

Json Schema Type Builder with Static Type Resolution for TypeScript
Other
4.65k stars 150 forks source link

Static resolution is not working as expected when using a generic function to generate a schema #813

Closed oshox closed 4 months ago

oshox commented 4 months ago

In the following code, the 'Static' function is not correctly showing the output of my 'schema' function. Both validator functions work as expected, though: (using 0.32.20)

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

type Schemas = {
  readonly [key: string]: {
      readonly [key: string]: {
        readonly type: "number" | "text",
        readonly display: string,
      }
  }
}

const schemasObject: Schemas = {
  foo: { text1: {type: "text", display: "Text 1"}, number1: {type: "number", display: "Number 1"}},
  bar: { text2: {type: "text", display: "Text 2"}, number2: {type: "number", display: "Number 2"}}
} as const

function schema<ObjectKey extends keyof typeof schemasObject>(objectParameter: ObjectKey) {
  const schema = schemasObject[objectParameter]
  type Schema = (typeof schemasObject)[ObjectKey]
  type AllKeyNames = keyof Schema & string

  const allKeyNames = Object.keys(schema) as AllKeyNames[]

  const textType = Type.Optional(Type.String())
  const numberType = Type.Optional(Type.Number())

  type NumericKeys = {
    [K in AllKeyNames]: (Schema)[K] extends {
      type: "number"
    } ? K : never
  }[AllKeyNames]

  type TextKeys = {
    [K in AllKeyNames]: (Schema)[K] extends {
      type: "text"
    } ? K : never
  }[AllKeyNames]

  type NumericFilters = {
    [Key in `${NumericKeys}_filter`]: typeof numberType
  }
  type TextFilters = {
    [Key in `${TextKeys}_filter`]: typeof textType
  }

  const numericFilters = {} as NumericFilters
  const textFilters = {} as TextFilters

  for (const keyName of allKeyNames) {
    switch (schema[keyName].type) {
      case "number": {
        numericFilters[`${keyName as NumericKeys}_filter`] = numberType
        break
      }
      case "text": {
        textFilters[`${keyName as TextKeys}_filter`] = textType
      }
    }
  }

  return Type.Object({
    ...numericFilters,
    ...textFilters
  }, { additionalProperties: false })
}

const fooSchema = schema("foo")
type FooSchema = Static<typeof fooSchema>
const barSchema = schema("bar")
type BarSchema = Static<typeof barSchema>

import { TypeCompiler } from '@sinclair/typebox/compiler'
const fooValidator = TypeCompiler.Compile(fooSchema)
const barValidator = TypeCompiler.Compile(barSchema)

const fooObject = {
  number1_filter: 123,
  text1_filter: 'example'
}

const barObject = {
  number2_filter: 13,
  text2_filter: 'example2'
}

console.log('Valid object is valid:', fooValidator.Check(fooObject))
console.log('Errors:', ...fooValidator.Errors(fooObject))

console.log('Invalid object is valid:', fooValidator.Check(barObject))
console.log('Errors', ...fooValidator.Errors(barObject))

console.log('Valid object is valid:', barValidator.Check(barObject))
console.log('Errors:', ...barValidator.Errors(barObject))

console.log('Invalid object is valid:', barValidator.Check(fooObject))
console.log('Errors', ...barValidator.Errors(fooObject))

The Static Types will show correctly after removing the type from schemaObject: const schemasObject: Schemas = { becomes const schemasObjec = {

But then Typescript throws an error when I try to access a nested property: switch (schema[keyName].type) {

Is this behavior expected and the result of me doing something wrong?

sinclairzx81 commented 4 months ago

@oshox Hi,

Is this behavior expected and the result of me doing something wrong?

This mostly comes down to TypeScript not having adequate information to derive the correct types from the runtime implementation of schema<...>(). Given the complexity involved mapping from typeof schemasObject into the target structures, you're going to need to go a bit "deeper" on type programmability (which is largely unavoidable)

I've had go reimplementing the type and runtime logic using similar techniques TypeBox uses internally. Generally the implementation of these kinds of mappings require you to define the source structures (Source Types) and the associated runtime / static logic that gradually remap the type at varying levels. Given the requirement to rename property names, and generate mappings of custom schematics, things can get a bit complex.

The following implementation has been tested locally as should work ok. Scroll down for the Usage.

TypeScript Inference Example Here

import * as TB from '@sinclair/typebox'

// ------------------------------------------------------------------
// Source Types
// ------------------------------------------------------------------
type Descriptor = Readonly<{ type: 'number' | 'text', display: string }>
type Schema = Readonly<{ [key: string]: Descriptor }>
type Schemas = Readonly<{ [key: string]: Schema }>

// ------------------------------------------------------------------
// TFromDescriptor
// ------------------------------------------------------------------
type TFromDescriptor<T extends Schema, K extends PropertyKey> = K extends keyof T 
  ? TB.Evaluate<Record<`${TB.Assert<K, string>}_filter`, 
      T[K]['type'] extends 'number' ? TB.TOptional<TB.TNumber> :
      T[K]['type'] extends 'text' ? TB.TOptional<TB.TString> :
      TB.TNever>> 
  : {}
function FromDescriptor<T extends Schema, K extends keyof T>(schema: T, key: K): TFromDescriptor<T, K> {
  return {
    [`${key as string}_filter`]: 
      schema[key]['type'] === 'number' ? TB.Optional(TB.Number(schema[key])) :
      schema[key]['type'] === 'text' ? TB.Optional(TB.String(schema[key])) :
      TB.Never()
  } as never
}
// ------------------------------------------------------------------
// FromSchemaReduce
// ------------------------------------------------------------------
type TFromSchemaReduce<T extends Schema, K extends PropertyKey[], Acc extends Record<PropertyKey, TB.TSchema> = {}> = (
  K extends [infer L extends PropertyKey, ...infer R extends PropertyKey[]]
    ? TFromSchemaReduce<T, R, Acc & TFromDescriptor<T, L>>
    : TB.TObject<TB.Evaluate<Acc>>
)
function FromSchemaReduce<T extends Schema, K extends PropertyKey[]>(schema: T, keys: K): TFromSchemaReduce<T, K> {
  const properties = keys.reduce((Acc, L) => {
    return { ...Acc, ...FromDescriptor(schema, L as keyof T) }
  }, {} as TB.TProperties)
  return TB.Type.Object(properties, { additionalProperties: false }) as never
}
// ------------------------------------------------------------------
// FromSchema
// ------------------------------------------------------------------
type TFromSchema<T extends Schema, K extends PropertyKey[] = TB.UnionToTuple<keyof T>> = TFromSchemaReduce<T, K>
function FromSchema<T extends Schema>(schema: T): TFromSchema<T> {
  return FromSchemaReduce(schema, Object.keys(schema)) as never
}
// ------------------------------------------------------------------
// TFromSchemas
// ------------------------------------------------------------------
type TFromSchemas<T extends Schemas> = TB.Evaluate<{
  -readonly [K in keyof T]: TFromSchema<T[K]>
}>
function FromSchemas<T extends Schemas>(schemas: T): TFromSchemas<T> {
  return Object.keys(schemas).reduce((Acc, K) => {
    return { ...Acc, [K]: FromSchema(schemas[K]) }
  }, {}) as never
}

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

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

const Mapped = FromSchemas({
  foo: { text1: { type: "text", display: "Text 1" }, number1: { type: "number", display: "Number 1" } },
  bar: { text2: { type: "text", display: "Text 2" }, number2: { type: "number", display: "Number 2" } }
} as const)

const fooSchema = Mapped["foo"]
type FooSchema = Static<typeof fooSchema>
const barSchema = Mapped["bar"]
type BarSchema = Static<typeof barSchema>

const fooValidator = TypeCompiler.Compile(fooSchema)
const barValidator = TypeCompiler.Compile(barSchema)

const fooObject = {
  number1_filter: 123,
  text1_filter: 'example'
}

const barObject = {
  number2_filter: 13,
  text2_filter: 'example2'
}

console.log('Valid object is valid:', fooValidator.Check(fooObject))
// console.log('Errors:', ...fooValidator.Errors(fooObject))

console.log('Invalid object is valid:', fooValidator.Check(barObject))
// console.log('Errors', ...fooValidator.Errors(barObject))

console.log('Valid object is valid:', barValidator.Check(barObject))
/// console.log('Errors:', ...barValidator.Errors(barObject))

console.log('Invalid object is valid:', barValidator.Check(fooObject))
// console.log('Errors', ...barValidator.Errors(fooObject))

Hope this helps S

sinclairzx81 commented 4 months ago

@oshox Hiya,

Might close off this issue as the above example should provide a good reference as to how to approach advanced type mapping in TypeBox. As noted, the inference isn't working here (as per original snippet) due to TypeScript being unable to derive the exact types from the function implementation. In cases like this you will need to help it along by writing explicit mappings (on the return type) that computes the anticipated output type.

If you have any other questions, feel free to ping on this thread. Cheers! S

oshox commented 4 months ago

Thank you for the response. You explanation solves my issue, and I will be able to implement those concepts in my other projects. I appreciate your time. Please close the issue.