sinclairzx81 / typebox

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

Mapping types erasing schema type #622

Closed tsujp closed 1 year ago

tsujp commented 1 year ago

I'm attempting to write a typed router and I'm stuck on what I think is a TypeBox issue or a fundamental quirk of TypeScript. I've spent a few hours trying to solve it myself but cannot figure out where the real error is coming from but I think I've narrowed it down.

The goal is to define a function interface which takes a route path and a schema object where every slug (parameterised route variable) must be in the schema object and both must have no extra keys with respect to each other. I can achieve this but subsequently calling Static on the schema object results in said type erasure.

Using a proper Type.Object as the schema object prevents type erasure but loses the slug and schema object key checking.

Here's my best attempt thus far:

type ToLower<T extends readonly string[]> = { [K in keyof T]: Lowercase<T[K]> }

const methodsUpper = ['GET', 'POST', 'PUT', 'DELETE'] as const
const methodsLower = methodsUpper.map((v) => v.toLowerCase()) as unknown as ToLower<
   typeof methodsUpper
>

type HttpMethods = typeof methodsLower[number]

// Creates a string union route path segments. Effectively splitting at each
//   `/` where each split is a member of the union.
type TakeSegments<P extends string> =
    P extends `/${infer Path}`
        ? Path extends `${infer Segment}/${infer Rest}`
            ? Segment | TakeSegments<`/${Rest}`>
            : Path
        : never

type TakeSlug<P extends string> =
    P extends `{${infer Slug}}`
        ? Slug
        : never

type TypedRouter = {
    [M in HttpMethods]: <
        Path extends string,
        ValidationSchema extends TProperties,
    >(
        path: Path,
        validation: TPickProperties<ValidationSchema, TakeSlug<TakeSegments<Path>>>,
        handler: (ctx: Static<TObject<ValidationSchema>>) => Response
    ) => Response
}

const foo: TypedRouter = {}

foo.get(
    '/foo/{bar}/lorem/{ipsum}/{dolor}',
    // Using Type.Object fixes inference but breaks slug-key checking.
    {
        bar: Type.Integer(),
        ipsum: Type.String(),
        dolor: Type.Boolean(), // Comment me to receive a type error about missing key `dolor`
        // extra: Type.String(), // Uncomment me to receive a type error about additional key `extra`
    },
    (ctx) => {
        ctx.bar // Type is `unknown`, expect `number`.
        ctx.ipsum // Type is `unknown`, expect `string`.
        ctx.dolor // Type is `unknown`, expect `boolean`.

        return new Response('Foo bar')
    }
)
sinclairzx81 commented 1 year ago

@tsujp Hi,

Um, I don't think this is a TypeBox bug, but this usage would be running up against the extents of the TS compiler and inference capabilities. I had a quick hack around and largely ran into the same issues. I've tried to make the supporting types a bit more obvious here (you can test them individually), but the main issue seems to be TypeScript losing track of the specific TSchema when evaluating the type with this usage pattern.

TypeScript Link Here

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

// ------------------------------------------------------------------
// HttpMethods
// ------------------------------------------------------------------
export type ToLower<T extends readonly string[]> = { [K in keyof T]: Lowercase<T[K]> }
export type HttpMethods = typeof methodsLower[number]
const methodsUpper = ['GET', 'POST', 'PUT', 'DELETE'] as const
const methodsLower = methodsUpper.map((v) => v.toLowerCase()) as unknown as ToLower<
   typeof methodsUpper
>
// ------------------------------------------------------------------
// Parse
// ------------------------------------------------------------------
export type ParseComponents<P extends string> = P extends `/${infer Path}`
    ? Path extends `${infer Segment}/${infer Rest}`
      ? Segment | ParseComponents<`/${Rest}`>
        : Path
    : never
export type ParseSlugs<P extends string> = P extends `{${infer Slug}}` ? Slug : never
export type ParsePropertyUnion<T extends string> = ParseSlugs<ParseComponents<T>>
export type ParsePropertyNames<T extends string> = UnionToTuple<ParsePropertyUnion<T>>
export type ParseObjectProperties<T extends string[]> = 
  T extends [infer L extends string, ...infer R extends string[]]
    ? { [_ in L] : TSchema } & ParseObjectProperties<R> 
    : {}
// ------------------------------------------------------------------
// ValidationObject
// ------------------------------------------------------------------
export type ParseValidationObject<T extends string, S = ParsePropertyNames<T>> = S extends string[] 
  ? Evaluate<ParseObjectProperties<S>>
  : {}
export type StaticValidationObject<T> = T extends TProperties ? {
  [K in keyof T]: Static<T[K]>
} : never
// ------------------------------------------------------------------
// TypedRouter
// ------------------------------------------------------------------
export type TypedRouter = {
    [M in HttpMethods]: <
      P extends string,
      T = ParseValidationObject<P>
  >(
    path: P,
    validation: T, // evaluation of T (via Pick or other) will have TS lose track of generic types.
    handler: (ctx: StaticValidationObject<T>) => Response
  ) => Response
}
// ------------------------------------------------------------------
// Example
// ------------------------------------------------------------------
declare const foo: TypedRouter
foo.get(
  '/foo/{bar}/lorem/{ipsum}/{dolor}',
  // Using Type.Object fixes inference but breaks slug-key checking.
  {
    bar: Type.Integer(),
    ipsum: Type.String(),
    dolor: Type.Boolean(), // Comment me to receive a type error about missing key `dolor`
    // extra: Type.String(), // Uncomment me to receive a type error about additional key `extra`
  },
  (ctx) => {
    ctx.bar // Type is `unknown`, expect `number`.
    ctx.ipsum // Type is `unknown`, expect `string`.
    ctx.dolor // Type is `unknown`, expect `boolean`.
    return new Response('Foo bar')
  }
)

So, the above does do inference, however the extra property is allowed (which is probably not desirable). Unfortunately, I can't seem to have TS retain the generic TSchema AND ensure additional properties are disallowed. So, not too sure here...however, you can repro TS losing track of generics by forcing TS to evaluate the type (this also prevents disallowed properties, but at a cost of losing inference)

TypeScript Link Here

type BreakInfer<T> = T extends infer O ? O : never
//                                   ^ force evaluation here                                                          

export type TypedRouter = {
    [M in HttpMethods]: <
      P extends string,
      T = ParseValidationObject<P>
  >(
    path: P,
    validation: BreakInfer<T>, // broken here
    handler: (ctx: StaticValidationObject<T>) => Response
  ) => Response
}

I expect there would be a way to implement this (it might just take some refactoring and deep diving into various ways to express this form of mapping), but yeah, I don't think this is specifically related to TypeBox.

Hope this helps S

sinclairzx81 commented 1 year ago

@tsujp Hi!

Hey, might close off this issue as this isn't really related to TypeBox. But I noticed that -n- (https://github.com/somebody1234) of the TS discord server produced a pretty great working solution, so will link this below for reference.

TypeScript Link Here

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

// Boilerplate stuff
const httpMethods = ['get', 'post', 'put', 'delete'] as const
export type HttpMethods = typeof httpMethods[number]

type TakeSegments<P extends string> =
    P extends `/${infer Segment}/${infer Rest}`
        ? Segment | TakeSegments<`/${Rest}`>
        : P extends `/${infer Segment}`
            ? Segment
            : never

type TakeSlug<P extends string> =
    P extends `{${infer Slug}}`
        ? Slug
        : never
// End boilerplate

type TypedRouter = {
    [M in HttpMethods]: <
        Path extends `/${string}`,
        Validation extends TProperties,
    >(
        path: Path,
        validation: never extends Validation ? Pick<Validation & Record<TakeSlug<TakeSegments<Path>>, TSchema>, TakeSlug<TakeSegments<Path>>> : Validation,
        handler: (ctx: Static<NonNullable<TObject<Validation>>>) => Response,
    ) => Response
}

declare const foo: TypedRouter

foo.get(
    '/foo/{bar}/lorem/{ipsum}/{dolor}',
    {
        bar: Type.Integer(),
        ipsum: Type.Optional(Type.String()),
        dolor: Type.Boolean(), // Commenting this should complain about missing key `dolor`.
        // extra: Type.String(), // Uncommenting this sould complain about additional key `extra`.
    },
  // The ctx parameter should narrow the types as expected below.
    (ctx) => {
    // ^?
        ctx.bar // Expect `number`
        //  ^?

        ctx.ipsum // Expect `string | undefined`
        //   ^?

        ctx.dolor // Expect `boolean`
        //   ^?

        ctx.extra // Expect this to be an error (always) even if `extra` is uncommented in the schema because it's a valid slug
        //   ^?

        return new Response('Foo bar')
    },
)

Quite an interesting type level puzzle this one :) Will close off this issue for now All the best! S

tsujp commented 1 year ago

@tsujp Hi!

Hey, might close off this issue as this isn't really related to TypeBox. But I noticed that -n- (https://github.com/somebody1234) of the TS discord server produced a pretty great working solution, so will link this below for reference.

Hey hey, as you know (we just chatted in Discord, but also stating here for clarity) that was me asking there! TypeHole in Discord had a good very-close solution too.

I was going to say in your original reply why chose:

export type StaticValidationObject<T> = T extends TProperties ? {
  [K in keyof T]: Static<T[K]>
} : never

versus

export type StaticValidationObject<T> = T extends TProperties
  ? Static<TObject<T>
  : never

The former will incorrectly infer ipsum: Type.Optional(Type.String()) whereas the latter will correctly infer it.

An educated guess is that Static<TObject<T>> is correctly evaluating optional or readonly properties as TObject's static field is of type PropertiesReduce which itself is constructed of type PropertiesReducer which is doing said evaluation whereas Static<T[K]> for a simple type like Type.String() whose static field does not comprise of type PropertiesReducer is simply returning string.