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
Conversation
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).
93333c7
to
ad6479e
Compare
@aleclarson Hi! Thanks again for taking a look into TypeBox.
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 While I do agree that the flattened type is generally more presentable. TypeBox does actually offer two options to produce a simplified flattened type, CompositeThe 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
// } MappedAnother 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. |
Very interesting, thanks for the thorough replies everywhere. It really helps! The 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 |
@aleclarson Hey, no problem :) If the expected output type is 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' }
// }
// ]
// }
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 |
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 |
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