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

fix(types): use Simplify in TIntersect for better type readability #784

Closed

Conversation

aleclarson
Copy link

@aleclarson aleclarson commented Mar 10, 2024

Intersected object types are lazily resolved by TypeScript until the type is mapped and intersected with an empty object, I guess. Not really sure why it works that way, but doing this leads to a nicer DX.

Before

Screen Shot 2024-03-10 at 16 58 26

After

Screen Shot 2024-03-10 at 16 58 48

Intersected object types are lazily resolved by TypeScript until the type is mapped and intersected with an empty object, I guess. Not really sure why it works that way, but doing this leads to a nicer DX (screenshots included in the PR).
@sinclairzx81
Copy link
Owner

sinclairzx81 commented Mar 12, 2024

@aleclarson Hi! Thanks again for taking a look into TypeBox.

Intersected object types are lazily resolved by TypeScript until the type is mapped and intersected with an empty object, I guess. Not really sure why it works that way, but doing this leads to a nicer DX.

This is actually intentional. The static types given by TypeBox are very closely aligned to the types representable in TypeScript. In the case of Intersect, TypeBox doesn't hide that the inferred type is a representation of a logical A & B as this is the type expression given by TypeScript. It is also an indication that the underlying runtime type is expressed with allOf in Json Schema (which can loosely be thought of as an unevaluated lazy object type in some (but not all) intersection cases)

While I do agree that the flattened type is generally more presentable. TypeBox does actually offer two options to produce a simplified flattened type, Composite and Mapped. I'll explain these a bit below.

Composite

The Composite type computes the safe intersection of the given type array (so long as the elements are object-like). The return type will always be a new composited Object type. In this respect, we say it's safe to infer the simplified type as the underlying schema is a simplified object representation (not expressed via allOf)

TypeScript Link Here

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

const T = Type.Composite([                    // const T = { // change to Intersect and compare representations
    Type.Object({ x: Type.Number() }),        //   type: 'object',
    Type.Object({ y: Type.Number() }),        //   required: ['x', 'y', 'z'].
    Type.Object({ z: Type.Number() }),        //   properties: {
])                                            //     x: { type: 'number' },
                                              //     y: { type: 'number' },
                                              //     z: { type: 'number' }
                                              //   }
                                              // }

type T = Static<typeof T>                     // type T = {
                                              //   x: number,
                                              //   y: number,
                                              //   z: number
                                              // }

Mapped

Another approach is to use homomorphic mapped types. This implementation is somewhat more involved (as it's expressing TS mapped types), but achieves the same result. Note that this approach is somewhat closer to the implementation provided by this PR.

TypeScript Link Here

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

// ---------------------------------------------------------
// TypeScript
// ---------------------------------------------------------

type A = { x: number } & { y: number } & { z: number }

type B = { [K in keyof A]: A[K] }             // type B = {
                                              //   x: number,
                                              //   y: number,
                                              //   z: number,
                                              // }

// ---------------------------------------------------------
// TypeBox
// ---------------------------------------------------------

const A = Type.Intersect([           // const A = { 
  Type.Object({ x: Type.Number() }), //   anyOf: [
  Type.Object({ y: Type.Number() }), //     { type: 'object', required: ['x'], properties: { x: { type: 'number' } } },
  Type.Object({ z: Type.Number() }), //     { type: 'object', required: ['y'], properties: { y: { type: 'number' } } },
])                                   //     { type: 'object', required: ['z'], properties: { z: { type: 'number' } } }
                                     //   }
                                     // }

const B = Type.Mapped(Type.KeyOf(A), K => {   // const B = {
   return Type.Index(A, K)                    //   type: 'object',
})                                            //   required: ['x', 'y', 'z'],
                                              //   properties: {
                                              //     x: { type: 'number' },
                                              //     y: { type: 'number' },
                                              //     z: { type: 'number' }
                                              //   }
                                              // }

type B2 = Static<typeof B>                    // type B2 = {
                                              //   x: number,
                                              //   y: number,
                                              //   z: number,
                                              // }

Generally speaking, TypeBox tries to represent (as best it can) a runtime and static representation that are matching 1-1. Again, I do agree that the simplified object is generally preferable (as well as in Json Schema), however I'm reluctant to change the current inferred type if that type doesn't completely match the schema representation behind it (as there are cases where preserving the intersected type is required for certain kinds of type evaluation)

Thanks again for the PR (these are appreciated), but will need to close this one out as being out of scope.
Cheers!
S

@aleclarson
Copy link
Author

aleclarson commented Mar 12, 2024

Very interesting, thanks for the thorough replies everywhere. It really helps!

The Composite type seems like what I want, but it doesn't seem to support Union types, so it seems I have to do something like below, unless I'm missing something?

TypeScript Link Here

type RequiredErrorProps = TObject<{ message: TString }>
type CodedError = TObject<{ code: TLiteral<string> }>

// Would prefer something like this
type Example1<T extends CodedError> =
  TComposite<[TIntersect<[T, RequiredErrorProps]>]>

// Have to do this?
type Example2<T extends CodedError> =
  T extends TSchema ? TComposite<[TIntersect<[T, RequiredErrorProps]>]> : never

// Examples below…

type MyError =
  | TObject<{ code: TLiteral<'x'>; x: TString }>
  | TObject<{ code: TLiteral<'y'>, y: TString }>

// Example1<MyError> (undesired)
type Output1 = {
    code: "x" | "y";
    message: string;
}

// Example2<MyError> (spot on)
type Output2 = {
    code: "x";
    message: string;
    x: string;
} | {
    code: "y";
    message: string;
    y: string;
}

I assume TB has something for distributing a union type, but don't know where to look. Of course, the ideal type of MyError would be a TUnion. I explored MyError as a TUnion briefly, but hit some discouraging issues (link here).

@sinclairzx81
Copy link
Owner

sinclairzx81 commented Mar 12, 2024

@aleclarson Hey, no problem :)

If the expected output type is Output2 (above), You can refactor the implementation a little bit to get the type you need. The following implements them using Type.* (showing both inference and runtime schema)

TypeScript Link Here

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

const RequiredErrorProps = Type.Object({ message: Type.String() })
const CodedError = Type.Object({ code: Type.String() })

const ErrorX = Type.Composite([ 
  RequiredErrorProps, 
  CodedError, 
  Type.Object({ code: Type.Literal('x'), x: Type.String() }) 
])

const ErrorY = Type.Composite([ 
  RequiredErrorProps, 
  CodedError, 
  Type.Object({ code: Type.Literal('y'), y: Type.String() }) 
])


type MyError = Static<typeof MyError>        // type MyError = {
                                             //  message: string;
                                             //   code: "x";
                                             //   x: string;
                                             // } | {
                                             //   message: string;
                                             //   code: "y";
                                             //   y: string;
                                             // }


const MyError = Type.Union([ErrorX, ErrorY]) // const T = {
                                             //   anyOf: [{
                                             //     type: 'object',
                                             //     required: ['message', 'code', 'x'],
                                             //     properties: {
                                             //       message: { type: 'string' },
                                             //       code: {
                                             //         allOf: [
                                             //           { type: 'string' },
                                             //           { type: 'string', const: 'x' }
                                             //         ]
                                             //       },
                                             //       x: { type: 'string' }
                                             //     }
                                             //   }, {
                                             //     type: 'object',
                                             //     required: ['message', 'code', 'y'],
                                             //     properties: {
                                             //       message: { type: 'string' },
                                             //       code: {
                                             //         allOf: [
                                             //           { type: 'string' },
                                             //           { type: 'string', const: 'y' }
                                             //         ]
                                             //       },
                                             //       y: { type: 'string' }
                                             //     }
                                             //   ]
                                             // }

Note that Composite will currently take the Intersection for any overlapping interior properties (noting the code property is expressed as an allOf). This is actually an area where TypeBox needs to improve upon (where it should evaluate to the most "narrow" type (i.e. the literal)), However the schema will validate inline with the TS type (where Json Schema interprets the allOf as a series of logical AND statements). If the allOf is an problem, you can omit the CodedError from the Composite.


If it's helpful, you can actually debug / repro various TS constructs using the TB Workbench. This tool allows you specify types using vanilla TypeScript syntax and generate the TypeBox equivalent code. It's a good way to push out the extents of the library (I use it for debugging) as well as author types in a more familiar way.

I've setup a couple of examples at the links below.

Example Representation: Interfaces

Example Representation: Type Aliases

Hope this helps
S

@aleclarson
Copy link
Author

aleclarson commented Mar 13, 2024

Hey @sinclairzx81, that solution works of course, but I'm trying to avoid manual composition if that makes sense. The error schemas are provided to a custom type function (loosely defined below):

function MaybeErrorResponse(
  responseSchema: TProperties,
  errorSchemas: TObject<{ code: TLiteral<string> }>[]
): TSchema

The goal is to avoid manually declaring the error type for each possible error.

const ErrorX = t.Object({
  code: t.Literal('x'),
  x: t.String(),
})

const ErrorY = t.Object({
  code: t.Literal('y'),
  y: t.String(),
})

const MyResponse = MaybeErrorResponse(
  { foo: TString },
  [ErrorX, ErrorY]
)

type MyResponse = t.Static<typeof MyResponse>
/*
  type MyResponse =
    | { foo: string; error?: undefined }
    | { error: { code: 'x'; message: string; x: string }
    | { error: { code: 'y'; message: string; y: string }
*/

Notice the lack of unnecessary boilerplate.

@aleclarson
Copy link
Author

Also, I should say that I'm satisfied with my current solution (not fully shared here), even though it kind of "escapes" TypeBox and thus forces me to manually define the result type (rather than use Static<>). The point of my previous comment is only to share context that could help you understand my use case slightly better. As such, I won't be using any recommended solution for now at least, but don't let that stop you from sharing the best practices in case others find this thread. Thanks again!

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

Successfully merging this pull request may close these issues.

None yet

2 participants