Skip to content

Commit 48cb05d

Browse files
authored
Merge pull request #23 from reilem/relax-bbox-typing
Relax bbox typing
2 parents af47005 + 039262f commit 48cb05d

File tree

8 files changed

+233
-101
lines changed

8 files changed

+233
-101
lines changed

README.md

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77

88
This repository contains GeoJSON schemas for the [Zod](https://github.com/colinhacks/zod) validation library by [@colinhacks](https://x.com/colinhacks).
99

10-
The schemas are based on the GeoJSON specification [RFC 7946](https://datatracker.ietf.org/doc/html/rfc7946). They validate the structure of the GeoJSON objects, types, and the validity of the dimensions, geometries and
11-
bounding boxes.
10+
The schemas are based on the GeoJSON specification RFC 7946. They validate the structure of the GeoJSON objects and types, as well as the validity of the dimensions, geometries, and bounding boxes.
1211

1312
The schemas and inferred types are designed to be fully compatible with the
1413
[@types/geojson](https://www.npmjs.com/package/@types/geojson) TypeScript types.
@@ -89,47 +88,46 @@ import type {
8988
} from "zod-geojson";
9089
```
9190

92-
### Dimensionality
91+
### Strict Position Typing
92+
93+
To maintain "out of the box" compatibility with [@types/geojson](https://www.npmjs.com/package/@types/geojson) the
94+
general schemas allow any position dimensionality.
95+
96+
```typescript
97+
import { GeoJSONPoint } from "zod-geojson";
98+
99+
// These are all valid
100+
const point3D: GeoJSONPoint = { type: "Point", coordinates: [0, 0, 0] };
101+
const point1D: GeoJSONPoint = { type: "Point", coordinates: [0] };
102+
const point0D: GeoJSONPoint = { type: "Point", coordinates: [] };
103+
const point4D: GeoJSONPoint = { type: "Point", coordinates: [0, 0, 0, 0] };
104+
```
93105

94-
This library exports specific schemas for 2D and 3D GeoJSONs, and their accompanying types:
106+
If you want strict position checking at compile time then you can use the provided 2D and 3D schemas/types.
95107

96108
```typescript
97109
import type {
98-
// 2D GeoJSON Schemas
110+
// 2D GeoJSON
99111
GeoJSON2DFeatureSchema,
100112
GeoJSON2DFeature,
101113
// ...
102-
// 2D GeoJSON Types
114+
// 2D GeoJSON Geometries
103115
GeoJSON2DPointSchema,
104116
GeoJSON2DPoint,
105117
// ...
106-
// 3D GeoJSON Schemas
118+
// 3D GeoJSON
107119
GeoJSON3DFeatureSchema,
108120
GeoJSON3DFeature,
109121
// ...
110-
// 3D GeoJSON Types
122+
// 3D GeoJSON Geometries
111123
GeoJSON3DPointSchema,
112124
GeoJSON3DPoint,
125+
// ...
113126
} from "zod-geojson";
114127
```
115128

116-
### Strict Position Typing
117-
118-
To maintain "out of box" compatibility with the `@types/geojson` types the general schemas allow any position
119-
dimensionality.
120-
121-
```typescript
122-
import { GeoJSONPoint } from "zod-geojson";
123-
124-
// These are all valid
125-
const point3D: GeoJSONPoint = { type: "Point", coordinates: [0, 0, 0] };
126-
const point1D: GeoJSONPoint = { type: "Point", coordinates: [0] };
127-
const point0D: GeoJSONPoint = { type: "Point", coordinates: [] };
128-
const point4D: GeoJSONPoint = { type: "Point", coordinates: [0, 0, 0, 0] };
129-
```
130-
131-
If you wish to have strict position typing, you can use the provided 2D and 3D schemas/types. These use strict position
132-
typing and will also restrict the bbox field to match the position dimension. For example:
129+
These use zod tuples to ensure positions are checked at compile time. However, to maintain interoperability
130+
with `@types/geojson`, bounding box dimensionality is not enforced at the type level, only during runtime validation.
133131

134132
```typescript
135133
import { GeoJSON2DPoint } from "zod-geojson";
@@ -143,7 +141,7 @@ const point3D: GeoJSON2DPoint = {
143141
const point2DWith3DBBox: GeoJSON2DPoint = {
144142
type: "Point",
145143
coordinates: [1.0, 2.0],
146-
bbox: [0.0, 0.0, 3.0, 4.0, 0.0, 0.0], // This will fail. BBox has 6 values instead of 4
144+
bbox: [0.0, 0.0, 3.0, 4.0, 0.0, 0.0], // This is allowed at type level, but will error during validation!
147145
};
148146
```
149147

@@ -191,7 +189,7 @@ This function takes three parameters:
191189

192190
Even if you wish to only customize one of these aspects, you will still need to pass all three parameters to the
193191
schema function. For convenience, this library exposes the default schemas which you can use as a base for your
194-
custom schemas. There are the following "default" main schemas that you can use if you do not wish to customize
192+
custom schemas. These are the following "default" main schemas that you can use if you do not wish to customize
195193
a certain aspect of the schema:
196194

197195
- `GeoJSONPositionSchema` - The main GeoJSON position schema which allows both 2D and 3D positions
@@ -231,7 +229,19 @@ As discussed above, if you only wish to customize the `properties` field, you wi
231229
by this library for this purpose.
232230

233231
```typescript
234-
import { GeoJSONFeatureGenericSchema, GeoJSONPositionSchema, GeoJSONGeometrySchema } from "zod-geojson";
232+
import {
233+
GeoJSONFeatureGenericSchema,
234+
GeoJSONPositionSchema,
235+
GeoJSONPropertiesSchema,
236+
GeoJSONGeometrySchema,
237+
} from "zod-geojson";
238+
239+
const NonNullPropertiesGeoJSONFeatureSchema = GeoJSONFeatureGenericSchema(
240+
GeoJSONPositionSchema,
241+
GeoJSONPropertiesSchema.unwrap(),
242+
GeoJSONGeometrySchema,
243+
);
244+
type NonNullPropertiesGeoJSONFeature = z.infer<typeof NonNullPropertiesGeoJSONFeatureSchema>;
235245

236246
const CustomPropertiesSchema = z.object({
237247
name: z.string(),

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "zod-geojson",
3-
"version": "1.5.0",
3+
"version": "1.6.0",
44
"description": "Zod schemas for GeoJSON",
55
"repository": {
66
"type": "git",

src/bbox.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,54 @@ import {
88
GeoJSONPositionSchema,
99
} from "./geometry/position";
1010

11-
export type _GeoJSONBBoxGeneric<P extends GeoJSONAnyPosition> = P extends GeoJSON3DPosition
12-
? [number, number, number, number, number, number]
13-
: P extends GeoJSON2DPosition
14-
? [number, number, number, number]
15-
: [number, number, number, number] | [number, number, number, number, number, number];
16-
1711
const _2DBBoxSchema = z.tuple([z.number(), z.number(), z.number(), z.number()]);
1812
const _3DBBoxSchema = z.tuple([z.number(), z.number(), z.number(), z.number(), z.number(), z.number()]);
13+
const _2DOr3DBBoxSchema = z.union([_2DBBoxSchema, _3DBBoxSchema]);
14+
type _2DOr3DBBox = z.infer<typeof _2DOr3DBBoxSchema>;
15+
16+
/**
17+
* Type representing a GeoJSON bounding box based on the provided position type.
18+
* - When the position is not specifically 2D or 3D, the bounding box can be either 2D or 3D.
19+
* - When the position is specifically 3D, the bounding box can be either 2D or 3D.
20+
* - When the position is specifically 2D, the bounding box can be either 2D or 3D.
21+
* - For other cases, it defaults to a number array.
22+
*
23+
* This rather strange approach is for compatibility with the @types/geojson definitions.
24+
*/
25+
export type _GeoJSONBBoxGeneric<P extends GeoJSONAnyPosition> = number extends P["length"]
26+
? _2DOr3DBBox
27+
: P extends GeoJSON3DPosition
28+
? _2DOr3DBBox
29+
: P extends GeoJSON2DPosition
30+
? _2DOr3DBBox
31+
: number[];
1932

2033
export type GeoJSONBBoxGenericSchemaType<P extends GeoJSONAnyPosition> = z.ZodType<_GeoJSONBBoxGeneric<P>>;
2134

2235
/**
23-
* Creates a Zod schema for a GeoJSON bounding box based on the provided position schema.
24-
* Zod tuples with 2 or 3 items are used to represent 2D and 3D bounding boxes respectively.
25-
* If the position schema is not a tuple with 2 or 3 items, it returns a union of both 2D and 3D bounding box schemas.
36+
* Creates a Zod schema for a GeoJSON bounding box roughly based on the provided position schema.
37+
* The typing is thrown completely out of the window here, and we reply on our automated tests
38+
* to ensure the runtime behavior is correct. The real bbox validation is implemented in the
39+
* schema `.check` methods for each specific GeoJSON schema, so this is just here for some
40+
* initial filtering of obviously invalid bbox values.
2641
*/
2742
export const GeoJSONBBoxGenericSchema = <P extends GeoJSONAnyPosition>(
2843
positionSchema: z.ZodType<P>,
2944
): GeoJSONBBoxGenericSchemaType<P> => {
30-
// Because zod cannot do conditional typing we need to do some hacky type casts to make this work
45+
// If the position is relaxed we assume either 2D or 3D bbox
46+
if (positionSchema instanceof z.ZodArray) {
47+
return _2DOr3DBBoxSchema as unknown as z.ZodType<GeoJSONBBoxGeneric<P>>;
48+
}
49+
// If the position is a tuple, we can infer the dimension from its length
3150
if (positionSchema instanceof z.ZodTuple) {
3251
const itemCount = positionSchema.def.items.length;
33-
if (itemCount === 2) {
34-
return _2DBBoxSchema as unknown as z.ZodType<GeoJSONBBoxGeneric<P>>;
35-
}
36-
if (itemCount === 3) {
37-
return _3DBBoxSchema as unknown as z.ZodType<GeoJSONBBoxGeneric<P>>;
38-
}
52+
return z
53+
.number()
54+
.array()
55+
.length(itemCount * 2) as unknown as z.ZodType<GeoJSONBBoxGeneric<P>>;
3956
}
40-
// If the position is not a tuple, we can't infer the dimension, and we return a union of 2D and 3D bbox
41-
return z.union([_2DBBoxSchema, _3DBBoxSchema]) as unknown as z.ZodType<GeoJSONBBoxGeneric<P>>;
57+
// If the position is not a tuple, we can't infer the dimension, so we return a simple number array
58+
return z.number().array() as unknown as z.ZodType<GeoJSONBBoxGeneric<P>>;
4259
};
4360
export type GeoJSONBBoxGeneric<P extends GeoJSONAnyPosition> = z.infer<ReturnType<typeof GeoJSONBBoxGenericSchema<P>>>;
4461

src/geojson.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ export type GeoJSONGenericSchemaType<
2121
R extends GeoJSONProperties,
2222
G extends GeoJSONGeometryGeneric<P> | null,
2323
> = z.ZodDiscriminatedUnion<
24-
[z.ZodType<G>, GeoJSONFeatureGenericSchemaType<P, R, G>, GeoJSONFeatureCollectionGenericSchemaType<P, R, G>],
24+
[
25+
DiscriminableGeometrySchema<P, G>,
26+
GeoJSONFeatureGenericSchemaType<P, R, G>,
27+
GeoJSONFeatureCollectionGenericSchemaType<P, R, G>,
28+
],
2529
"type"
2630
>;
2731

tests/bbox.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { describe, expect, it } from "@jest/globals";
22
import { point as turfPoint } from "@turf/helpers";
33
import type GeoJSONTypes from "geojson";
4-
import { ZodError } from "zod/v4";
4+
import z, { ZodError } from "zod";
55
import { bbox2D, bbox3D } from "../examples/bbox";
66
import { GeoJSONBBox, GeoJSONBBoxSchema } from "../src";
7-
import { GeoJSON2DBBox, GeoJSON2DBBoxSchema, GeoJSON3DBBox, GeoJSON3DBBoxSchema } from "../src/bbox";
7+
import {
8+
GeoJSON2DBBox,
9+
GeoJSON2DBBoxSchema,
10+
GeoJSON3DBBox,
11+
GeoJSON3DBBoxSchema,
12+
GeoJSONBBoxGenericSchema,
13+
} from "../src/bbox";
814

915
const bbox4D = [0, 0, 0, 0, 0, 0, 0, 0];
1016

@@ -69,6 +75,20 @@ describe("GeoJSONBBox", () => {
6975
});
7076
});
7177

78+
describe("4D", () => {
79+
const GeoJSON4DBBoxSchema = GeoJSONBBoxGenericSchema(z.tuple([z.number(), z.number(), z.number(), z.number()]));
80+
81+
it("allows a 4D bbox", () => {
82+
expect(GeoJSON4DBBoxSchema.parse(bbox4D)).toEqual(bbox4D);
83+
});
84+
it("does not allow a 2D bbox", () => {
85+
expect(() => GeoJSON4DBBoxSchema.parse(bbox2D)).toThrow(ZodError);
86+
});
87+
it("does not allow a 3D bbox", () => {
88+
expect(() => GeoJSON4DBBoxSchema.parse(bbox3D)).toThrow(ZodError);
89+
});
90+
});
91+
7292
describe("turf.js", () => {
7393
it("validates bbox from turf.js", () => {
7494
const bbox = turfPoint([0, 0, 0], {}, { bbox: [0, 0, 0, 1, 1, 1] }).bbox;

tests/feature.test.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from "@jest/globals";
2+
import { feature as turfFeature, point as turfPoint } from "@turf/helpers";
23
import type GeoJSONTypes from "geojson";
34
import z, { ZodError } from "zod/v4";
4-
import { feature as turfFeature, point as turfPoint } from "@turf/helpers";
55

66
import {
77
geoJsonFeatureGeometryCollection2D,
@@ -17,11 +17,13 @@ import { geoJsonPoint3D } from "../examples/geometry/point";
1717
import {
1818
GeoJSON2DFeature,
1919
GeoJSON2DFeatureSchema,
20+
GeoJSON2DGeometry,
2021
GeoJSON2DPointSchema,
2122
GeoJSON2DPolygonSchema,
2223
GeoJSON2DPositionSchema,
2324
GeoJSON3DFeature,
2425
GeoJSON3DFeatureSchema,
26+
GeoJSON3DGeometry,
2527
GeoJSON3DPointSchema,
2628
GeoJSON3DPolygonSchema,
2729
GeoJSON3DPositionSchema,
@@ -243,17 +245,26 @@ describe("GeoJSONFeature", () => {
243245
GeoJSON4DGeometrySchema,
244246
);
245247

248+
const feature = {
249+
...geoJsonFeaturePoint2D,
250+
geometry: {
251+
type: "Point",
252+
coordinates: [1.0, 2.0, 3.0, 4.0],
253+
},
254+
};
255+
246256
it("allows feature with 4D positions", () => {
247-
const feature = {
248-
...geoJsonFeaturePoint2D,
249-
geometry: {
250-
type: "Point",
251-
coordinates: [1.0, 2.0, 3.0, 4.0],
252-
},
253-
};
254257
expect(GeoJSON4DFeatureSchema.parse(feature)).toEqual(feature);
255258
});
256259

260+
it("allows a feature with 4D position and valid bbox", () => {
261+
const featureWithBBox = {
262+
...feature,
263+
bbox: [1.0, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0, 4.0],
264+
};
265+
expect(GeoJSON4DFeatureSchema.parse(featureWithBBox)).toEqual(featureWithBBox);
266+
});
267+
257268
it("does not allow feature with 3D positions", () => {
258269
expect(() => GeoJSON4DFeatureSchema.parse(geoJsonFeaturePoint3D)).toThrow(ZodError);
259270
});
@@ -268,6 +279,14 @@ describe("GeoJSONFeature", () => {
268279
};
269280
expect(() => GeoJSON4DFeatureSchema.parse(feature)).toThrow(ZodError);
270281
});
282+
283+
it("does not allow a feature with 4D positions and invalid bbox", () => {
284+
const featureWithInvalidBBox = {
285+
...feature,
286+
bbox: [1.0, 2.0, 3.0, 4.0], // Invalid bbox for 4D position
287+
};
288+
expect(() => GeoJSON4DFeatureSchema.parse(featureWithInvalidBBox)).toThrow(ZodError);
289+
});
271290
});
272291

273292
describe("Custom properties", () => {
@@ -586,15 +605,26 @@ export const feature5: GeoJSONTypes.Feature<GeoJSONTypes.Point> = geoJsonFeature
586605

587606
export const feature6: GeoJSONTypes.Feature = geoJsonFeatureLineString2D as GeoJSONFeature;
588607

608+
export const feature7: GeoJSONTypes.Feature<GeoJSON3DGeometry> = geoJsonFeaturePoint3D;
609+
export const feature8: GeoJSONTypes.Feature<GeoJSON2DGeometry> = geoJsonFeaturePolygon2D;
610+
589611
/**
590612
* Test that @types/geojson matches our types
591613
*/
592-
export const feature7: GeoJSONFeature = feature1;
614+
export const feature11: GeoJSONFeature = feature1;
615+
export const feature12: GeoJSONFeature = feature2;
616+
export const feature13: GeoJSONFeature = feature3;
617+
export const feature14: GeoJSONFeature = feature4;
618+
export const feature15: GeoJSONFeature = feature5;
619+
export const feature16: GeoJSONFeature = feature6;
620+
621+
export const feature17: GeoJSON3DFeature = feature7;
622+
export const feature18: GeoJSON2DFeature = feature8;
593623

594624
/**
595625
* Test that turf.js matches our types
596626
*/
597-
export const feature8: GeoJSONFeature = turfFeature(geoJsonPoint3D);
598-
export const feature9: GeoJSONFeature = turfFeature(geoJsonPoint3D, { name: "hello" });
599-
export const feature10: GeoJSONFeature = turfPoint([0, 0, 0]);
600-
export const feature11: GeoJSONFeature = turfPoint([0, 0, 0], { extra: "field" });
627+
export const feature21: GeoJSONFeature = turfFeature(geoJsonPoint3D);
628+
export const feature22: GeoJSONFeature = turfFeature(geoJsonPoint3D, { name: "hello" });
629+
export const feature23: GeoJSONFeature = turfPoint([0, 0, 0]);
630+
export const feature24: GeoJSONFeature = turfPoint([0, 0, 0], { extra: "field" });

0 commit comments

Comments
 (0)