sinclairzx81 / typebox

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

Issue regarding enums becoming never when using a partial deep method #857

Closed piyush-nudge closed 3 months ago

piyush-nudge commented 3 months ago

Hi @sinclairzx81 ,

While using the partial deep implementation on a schema, if there are any enums involved in the schema, their values are becoming null. For example, if the schema looks like this:

export const pagesSchema = Type.Object({
    id: Type.Optional(Type.String()),
    name: Type.String(),
    page_id: Type.String(),
    status: Type.Enum(PagesStatus),
    properties: Type.Object({}),
    components: Type.Array(componentSchema),
    image_key: Type.String(),
    image_url: Type.Optional(Type.String()),
    created_by: Type.String(),
    app_version: Type.String(),
    tag: Type.String(),
    used_in: Type.Optional(Type.Array(Type.String())),
    created_at: Type.Optional(Type.Date()),
    updated_at: Type.Optional(Type.Date()),
});

and PageStatus is:

export enum PagesStatus {
  ACTIVE = "active",
  INACTIVE = "inactive",
  DELETED = "deleted"
}

the compiled value after using the partial deep implementation mentioned here, the schema's type becomes this:

export const pagesSchemaOptional = PartialDeep(pagesSchema);
// if we do this
export type TPages = Static<typeof pagesSchemaOptional>;

// type becomes this
type TPages = {
    name?: string;
    properties?: {};
    id?: string;
    updated_at?: Date;
    created_at?: Date;
    status?: never; // issue in case of Enums
    components?: {
        name: string;
        component_id: string;
        height: number;
        width: number;
        x: number;
        y: number;
    }[];
    ...
    used_in?: string[];
}

But if we use Type.Union([ Type.Literal("..."), Type.Literal("..."), Type.Literal("...") ]) instead of Enums, the type of Tpages becomes what we intend. Why is this occuring, is it because of the implementation of DeepPartial for enums or the Static when we are using for the type of TPages?

sinclairzx81 commented 3 months ago

@piyush-nudge Hi, thanks for letting me know.

See below which fixes for the Enum case (updates to PartialDeep and TPartialDeep respectively). The issue here is that TypeBox treats enums as TUnion<TLiteral[]> and where the previous infer logic was getting hung on the defacto Union check. I've added a TEnum<...> clause first in the inference and runtime expression which short circuts the check specifically for enums. This should solve the issue.

TypeScript Link Here

import { TypeGuard, Type, TSchema, TIntersect, TEnum, TUnion, TObject, TPartial, TProperties, Evaluate } from '@sinclair/typebox'

// -------------------------------------------------------------------------------------
// TPartialDeepProperties
// -------------------------------------------------------------------------------------
export type TPartialDeepProperties<T extends TProperties> = {
  [K in keyof T]: TPartialDeep<T[K]>
}
function PartialDeepProperties<T extends TProperties>(properties: T): TPartialDeepProperties<T> {
  return Object.getOwnPropertyNames(properties).reduce((acc, key) => {
    return {...acc, [key]: PartialDeep(properties[key])}
  }, {}) as never
}
// -------------------------------------------------------------------------------------
// TPartialDeepRest
// -------------------------------------------------------------------------------------
export type TPartialDeepRest<T extends TSchema[], Acc extends TSchema[] = []> = (
  T extends [infer L extends TSchema, ...infer R extends TSchema[]]
    ? TPartialDeepRest<R, [...Acc, TPartialDeep<L>]>
    : Acc
)
function PartialDeepRest<T extends TSchema[]>(rest: [...T]): TPartialDeepRest<T> {
  return rest.map(schema => PartialDeep(schema)) as never
}
// -------------------------------------------------------------------------------------
// TPartialDeep
// -------------------------------------------------------------------------------------
export type TPartialDeep<T extends TSchema> = 
  T extends TEnum<infer S> ? TEnum<S> : // added - enum values are opaque to the type system, we just infer for S
  T extends TIntersect<infer S> ? TIntersect<TPartialDeepRest<S>> :
  T extends TUnion<infer S> ? TUnion<TPartialDeepRest<S>> :
  T extends TObject<infer S> ? TPartial<TObject<Evaluate<TPartialDeepProperties<S>>>> :
  T
export function PartialDeep<T extends TSchema>(schema: T): TPartialDeep<T> {
  return (
    TypeGuard.IsUnionLiteral(schema) ? schema : // added - enum representations are expressed as TUnion<TLiteral[]>
    TypeGuard.IsIntersect(schema) ? Type.Intersect(PartialDeepRest(schema.allOf)) :
    TypeGuard.IsUnion(schema) ? Type.Union(PartialDeepRest(schema.anyOf)) :
    TypeGuard.IsObject(schema) ? Type.Partial(Type.Object(PartialDeepProperties(schema.properties))) :
    schema
  ) as never
}

// -------------------------------------------------------------
// Example
// -------------------------------------------------------------

import { type Static } from '@sinclair/typebox'

export enum PagesStatus {
  ACTIVE = "active",
  INACTIVE = "inactive",
  DELETED = "deleted"
}
export const pagesSchema = PartialDeep(Type.Object({
  id: Type.Optional(Type.String()),
  name: Type.String(),
  page_id: Type.String(),
  status: Type.Enum(PagesStatus),
  properties: Type.Object({}),
  components: Type.Array(Type.Unknown()), // missing schema
  image_key: Type.String(),
  image_url: Type.Optional(Type.String()),
  created_by: Type.String(),
  app_version: Type.String(),
  tag: Type.String(),
  used_in: Type.Optional(Type.Array(Type.String())),
  created_at: Type.Optional(Type.Date()),
  updated_at: Type.Optional(Type.Date()),
}))

type T = Static<typeof pagesSchema> // type T = { ..., status?: PageStatus | undefined }

I'll update the prototype later tonight, did you want to give this a test run your side? Keep me posted S

piyush-nudge commented 3 months ago

Hi @sinclairzx81 , thank you for the quick response. The modified implementation you provided above works correctly for our case and gives the intended types. It is interesting that typebox treats Enums as Union of Literals. So I just wanted to ask is there actually any difference while defining a schema using Enums and Union Literals or they both have the same implementation under-the-hood, and also it would be nice to get some insights into performance when using either of those.