Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
oshox opened this issue Mar 27, 2024 · 3 comments

Comments

@oshox
Copy link

oshox commented Mar 27, 2024

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
Copy link
Owner

sinclairzx81 commented Mar 27, 2024

@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
Copy link
Owner

@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
Copy link
Author

oshox commented Mar 28, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants