Closed aleclarson closed 6 months ago
@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.
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
)
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
// }
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.
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
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?
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).
@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)
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 anallOf
). 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 theallOf
as a series of logical AND statements). If theallOf
is an problem, you can omit theCodedError
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
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.
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!
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
After