Closed tsujp closed 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.
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)
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
@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.
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 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
.
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: