Skip to content

Commit 87dadd5

Browse files
committed
special case empty object for jtd
fixes #2123
1 parent 01e644a commit 87dadd5

File tree

2 files changed

+83
-13
lines changed

2 files changed

+83
-13
lines changed

lib/types/jtd-schema.ts

+21-11
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ type EnumString<T> = [T] extends [never]
7474
: null
7575

7676
/** true if type is a union of string literals */
77-
type IsEnum<T> = null extends EnumString<Exclude<T, null>> ? false : true
77+
type IsEnum<T> = null extends EnumString<T> ? false : true
7878

7979
/** true only if all types are array types (not tuples) */
8080
// NOTE relies on the fact that tuples don't have an index at 0.5, but arrays
@@ -88,13 +88,18 @@ type IsElements<T> = false extends IsUnion<T>
8888
: false
8989

9090
/** true if the the type is a values type */
91-
type IsValues<T> = false extends IsUnion<Exclude<T, null>>
92-
? TypeEquality<keyof Exclude<T, null>, string>
91+
type IsValues<T> = false extends IsUnion<T> ? TypeEquality<keyof T, string> : false
92+
93+
/** true if type is a properties type and Union is false, or type is a discriminator type and Union is true */
94+
type IsRecord<T, Union extends boolean> = Union extends IsUnion<T>
95+
? null extends EnumString<keyof T>
96+
? false
97+
: true
9398
: false
9499

95-
/** true if type is a proeprties type and Union is false, or type is a discriminator type and Union is true */
96-
type IsRecord<T, Union extends boolean> = Union extends IsUnion<Exclude<T, null>>
97-
? null extends EnumString<keyof Exclude<T, null>>
100+
/** true if type represents an empty record */
101+
type IsEmptyRecord<T> = [T] extends [Record<string, never>]
102+
? [T] extends [never]
98103
? false
99104
: true
100105
: false
@@ -131,7 +136,7 @@ export type JTDSchemaType<T, D extends Record<string, unknown> = Record<string,
131136
? {type: "timestamp"}
132137
: // enums - only accepts union of string literals
133138
// TODO we can't actually check that everything in the union was specified
134-
true extends IsEnum<T>
139+
true extends IsEnum<Exclude<T, null>>
135140
? {enum: EnumString<Exclude<T, null>>[]}
136141
: // arrays - only accepts arrays, could be array of unions to be resolved later
137142
true extends IsElements<Exclude<T, null>>
@@ -140,15 +145,20 @@ export type JTDSchemaType<T, D extends Record<string, unknown> = Record<string,
140145
elements: JTDSchemaType<E, D>
141146
}
142147
: never
148+
: // empty properties
149+
true extends IsEmptyRecord<Exclude<T, null>>
150+
?
151+
| {properties: Record<string, never>; optionalProperties?: Record<string, never>}
152+
| {optionalProperties: Record<string, never>}
143153
: // values
144-
true extends IsValues<T>
154+
true extends IsValues<Exclude<T, null>>
145155
? T extends Record<string, infer V>
146156
? {
147157
values: JTDSchemaType<V, D>
148158
}
149159
: never
150160
: // properties
151-
true extends IsRecord<T, false>
161+
true extends IsRecord<Exclude<T, null>, false>
152162
? ([RequiredKeys<Exclude<T, null>>] extends [never]
153163
? {
154164
properties?: Record<string, never>
@@ -168,15 +178,15 @@ export type JTDSchemaType<T, D extends Record<string, unknown> = Record<string,
168178
additionalProperties?: boolean
169179
}
170180
: // discriminator
171-
true extends IsRecord<T, true>
181+
true extends IsRecord<Exclude<T, null>, true>
172182
? {
173183
[K in keyof Exclude<T, null>]-?: Exclude<T, null>[K] extends string
174184
? {
175185
discriminator: K
176186
mapping: {
177187
// TODO currently allows descriminator to be present in schema
178188
[M in Exclude<T, null>[K]]: JTDSchemaType<
179-
Omit<T extends {[C in K]: M} ? T : never, K>,
189+
Omit<T extends Record<K, M> ? T : never, K>,
180190
D
181191
>
182192
}

spec/types/jtd-schema.spec.ts

+62-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint-disable @typescript-eslint/no-empty-interface,no-void */
1+
/* eslint-disable @typescript-eslint/no-empty-interface,no-void,@typescript-eslint/ban-types */
22
import _Ajv from "../ajv_jtd"
33
import type {JTDSchemaType, SomeJTDSchemaType, JTDDataType} from "../../dist/jtd"
44
import chai from "../chai"
@@ -17,8 +17,14 @@ interface B {
1717
b?: string
1818
}
1919

20+
interface C {
21+
type: "c"
22+
}
23+
2024
type MyData = A | B
2125

26+
type Missing = A | C
27+
2228
interface LinkedList {
2329
val: number
2430
next?: LinkedList
@@ -32,6 +38,14 @@ const mySchema: JTDSchemaType<MyData> = {
3238
},
3339
}
3440

41+
const missingSchema: JTDSchemaType<Missing> = {
42+
discriminator: "type",
43+
mapping: {
44+
a: {properties: {a: {type: "float64"}}},
45+
c: {properties: {}},
46+
},
47+
}
48+
3549
describe("JTDSchemaType", () => {
3650
it("validation should prove the data type", () => {
3751
const ajv = new _Ajv()
@@ -69,6 +83,22 @@ describe("JTDSchemaType", () => {
6983
serialize(invalidData)
7084
})
7185

86+
it("validation should prove the data type for missingSchema", () => {
87+
const ajv = new _Ajv()
88+
const validate = ajv.compile(missingSchema)
89+
const validData: unknown = {type: "c"}
90+
91+
if (validate(validData)) {
92+
validData.type.should.equal("c")
93+
}
94+
should.not.exist(validate.errors)
95+
96+
if (ajv.validate(missingSchema, validData)) {
97+
validData.type.should.equal("c")
98+
}
99+
should.not.exist(validate.errors)
100+
})
101+
72102
it("should typecheck number schemas", () => {
73103
const numf: JTDSchemaType<number> = {type: "float64"}
74104
const numi: JTDSchemaType<number> = {type: "int32"}
@@ -286,12 +316,42 @@ describe("JTDSchemaType", () => {
286316
const emptyButFull: JTDSchemaType<{a: string}> = {}
287317
const emptyMeta: JTDSchemaType<unknown> = {metadata: {}}
288318

289-
// constant null not representable
319+
// constant null representable as nullable empty object
290320
const emptyNull: TypeEquality<JTDSchemaType<null>, never> = true
291321

292322
void [empty, emptyUnknown, falseUnknown, emptyButFull, emptyMeta, emptyNull]
293323
})
294324

325+
it("should typecheck empty records", () => {
326+
// empty record variants
327+
const emptyPro: JTDSchemaType<{}> = {properties: {}}
328+
const emptyOpt: JTDSchemaType<{}> = {optionalProperties: {}}
329+
const emptyBoth: JTDSchemaType<{}> = {properties: {}, optionalProperties: {}}
330+
const emptyRecord: JTDSchemaType<Record<string, never>> = {properties: {}}
331+
const notNullable: JTDSchemaType<{}> = {properties: {}, nullable: false}
332+
333+
// can't be null
334+
// @ts-expect-error
335+
const nullable: JTDSchemaType<{}> = {properties: {}, nullable: true}
336+
337+
const emptyNullUnion: JTDSchemaType<null | {}> = {properties: {}, nullable: true}
338+
const emptyNullRecord: JTDSchemaType<null | Record<string, never>> = {
339+
properties: {},
340+
nullable: true,
341+
}
342+
343+
void [
344+
emptyPro,
345+
emptyOpt,
346+
emptyBoth,
347+
emptyRecord,
348+
notNullable,
349+
nullable,
350+
emptyNullUnion,
351+
emptyNullRecord,
352+
]
353+
})
354+
295355
it("should typecheck ref schemas", () => {
296356
const refs: JTDSchemaType<number[], {num: number}> = {
297357
definitions: {

0 commit comments

Comments
 (0)