diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/swift-geo-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/swift-geo-Package.xcscheme
index 2629d5f..e796e9a 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/swift-geo-Package.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/swift-geo-Package.xcscheme
@@ -202,6 +202,20 @@
                ReferencedContainer = "container:">
             </BuildableReference>
          </BuildActionEntry>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "GeoJSON"
+               BuildableName = "GeoJSON"
+               BlueprintName = "GeoJSON"
+               ReferencedContainer = "container:">
+            </BuildableReference>
+         </BuildActionEntry>
       </BuildActionEntries>
    </BuildAction>
    <TestAction
@@ -310,6 +324,16 @@
                ReferencedContainer = "container:">
             </BuildableReference>
          </TestableReference>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "GeoJSONTests"
+               BuildableName = "GeoJSONTests"
+               BlueprintName = "GeoJSONTests"
+               ReferencedContainer = "container:">
+            </BuildableReference>
+         </TestableReference>
       </Testables>
    </TestAction>
    <LaunchAction
diff --git a/Package.swift b/Package.swift
index 359ba0e..195615d 100644
--- a/Package.swift
+++ b/Package.swift
@@ -17,6 +17,7 @@ let package = Package(
 		.library(name: "WGS84", targets: ["WGS84"]),
 		.library(name: "GeodeticGeometry", targets: ["GeodeticGeometry"]),
 		.library(name: "Turf", targets: ["Turf"]),
+		.library(name: "GeoJSON", targets: ["GeoJSON"]),
 	],
 	dependencies: [
 		.package(url: "https://github.com/apple/swift-algorithms", .upToNextMajor(from: "1.0.0")),
@@ -137,5 +138,19 @@ let package = Package(
 			"GeodeticConversions",
 			"WGS84Conversions",
 		]),
+
+		// 📄 GeoJSON representation
+		.target(
+			name: "GeoJSON",
+			dependencies: [
+				.target(name: "GeodeticGeometry"),
+				.target(name: "WGS84Turf"),
+				.product(name: "NonEmpty", package: "swift-nonempty"),
+			]
+		),
+		.testTarget(
+			name: "GeoJSONTests",
+			dependencies: ["GeoJSON"]
+		),
 	]
 )
diff --git a/Sources/GeoJSON/BoundingBox.swift b/Sources/GeoJSON/BoundingBox.swift
new file mode 100644
index 0000000..027ec10
--- /dev/null
+++ b/Sources/GeoJSON/BoundingBox.swift
@@ -0,0 +1,61 @@
+//
+//  BoundingBox.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import GeoModels
+
+/// A [GeoJSON Bounding Box](https://datatracker.ietf.org/doc/html/rfc7946#section-5).
+public protocol BoundingBox: GeoModels.BoundingBox, Codable {
+	
+	/// This bonding box, but type-erased.
+	var asAny: AnyBoundingBox { get }
+	
+}
+
+/// A two-dimensional ``BoundingBox``.
+public typealias BoundingBox2D = GeoModels.BoundingBox2D
+
+extension BoundingBox2D: BoundingBox {
+	
+	public var asAny: AnyBoundingBox { .twoDimensions(self) }
+	
+}
+
+/// A three-dimensional ``BoundingBox``.
+public typealias BoundingBox3D = GeoModels.BoundingBox3D
+
+extension BoundingBox3D: BoundingBox {
+	
+	public var asAny: AnyBoundingBox { .threeDimensions(self) }
+	
+}
+
+/// A type-erased ``BoundingBox``.
+public enum AnyBoundingBox: BoundingBox, Hashable, Codable {
+	
+	public static var zero: AnyBoundingBox = .twoDimensions(.zero)
+	
+	public func union(_ other: AnyBoundingBox) -> AnyBoundingBox {
+		switch (self, other) {
+		case let (.twoDimensions(self), .twoDimensions(other)):
+			return .twoDimensions(self.union(other))
+			
+		case let (.twoDimensions(bbox2d), .threeDimensions(bbox3d)),
+			 let (.threeDimensions(bbox3d), .twoDimensions(bbox2d)):
+			return .threeDimensions(bbox3d.union(bbox2d))
+			
+		case let (.threeDimensions(self), .threeDimensions(other)):
+			return .threeDimensions(self.union(other))
+		}
+	}
+	
+	case twoDimensions(BoundingBox2D)
+	case threeDimensions(BoundingBox3D)
+	
+	public var asAny: AnyBoundingBox { self }
+	
+}
diff --git a/Sources/GeoJSON/Errors.swift b/Sources/GeoJSON/Errors.swift
new file mode 100644
index 0000000..8b240b5
--- /dev/null
+++ b/Sources/GeoJSON/Errors.swift
@@ -0,0 +1,17 @@
+//
+//  Errors.swift
+//  SwiftGeo
+//
+//  Created by Rémi Bardon on 07/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import Foundation
+
+/// Error when creating ``LinearRingCoordinates``.
+///
+/// See [RFC 7946, section 3.1.6](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6).
+public enum LinearRingError: Error {
+	case firstAndLastPositionsShouldBeEquivalent
+	case notEnoughPoints
+}
diff --git a/Sources/GeoJSON/GeoJSON+Codable.swift b/Sources/GeoJSON/GeoJSON+Codable.swift
new file mode 100644
index 0000000..70059ec
--- /dev/null
+++ b/Sources/GeoJSON/GeoJSON+Codable.swift
@@ -0,0 +1,484 @@
+//
+//  GeoJSON+Codable.swift
+//  SwiftGeo
+//
+//  Created by Rémi Bardon on 07/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import Foundation
+import GeoModels
+
+//extension BinaryFloatingPoint {
+//
+//	/// Rounds the double to decimal places value
+//	fileprivate func roundedToPlaces(_ places: Int) -> Self {
+//		let divisor = pow(10.0, Double(places))
+//		return Self((Double(self) * divisor).rounded() / divisor)
+//	}
+//
+//}
+
+extension Double {
+	
+	/// Rounds the double to decimal places value
+	func roundedToPlaces(_ places: Int) -> Decimal {
+		let divisor = pow(10.0, Double(places))
+		return Decimal((self * divisor).rounded()) / Decimal(divisor)
+	}
+	
+}
+
+extension Latitude: Codable {
+	
+	public init(from decoder: Decoder) throws {
+		let container = try decoder.singleValueContainer()
+		
+		let decimalDegrees = try container.decode(Double.self)
+		
+		self.init(decimalDegrees: decimalDegrees)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.singleValueContainer()
+		
+		try container.encode(self.decimalDegrees.roundedToPlaces(6))
+	}
+	
+}
+
+extension Longitude: Codable {
+	
+	public init(from decoder: Decoder) throws {
+		let container = try decoder.singleValueContainer()
+		
+		let decimalDegrees = try container.decode(Double.self)
+		
+		self.init(decimalDegrees: decimalDegrees)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.singleValueContainer()
+		
+		try container.encode(self.decimalDegrees.roundedToPlaces(6))
+	}
+	
+}
+
+extension Altitude: Codable {
+	
+	public init(from decoder: Decoder) throws {
+		let container = try decoder.singleValueContainer()
+		
+		let meters = try container.decode(Double.self)
+		
+		self.init(meters: meters)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.singleValueContainer()
+		
+		try container.encode(self.meters.roundedToPlaces(3))
+	}
+	
+}
+
+extension Position2D: Codable {
+	
+	public init(from decoder: Decoder) throws {
+		var container = try decoder.unkeyedContainer()
+		
+		let longitude = try container.decode(Longitude.self)
+		let latitude = try container.decode(Latitude.self)
+		
+		self.init(latitude: latitude, longitude: longitude)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.unkeyedContainer()
+		
+		try container.encode(self.longitude)
+		try container.encode(self.latitude)
+	}
+	
+}
+
+extension Position3D: Codable {
+	
+	public init(from decoder: Decoder) throws {
+		var container = try decoder.unkeyedContainer()
+		
+		let longitude = try container.decode(Longitude.self)
+		let latitude = try container.decode(Latitude.self)
+		let altitude = try container.decode(Altitude.self)
+		
+		self.init(latitude: latitude, longitude: longitude, altitude: altitude)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.unkeyedContainer()
+		
+		try container.encode(self.longitude)
+		try container.encode(self.latitude)
+		try container.encode(self.altitude)
+	}
+	
+}
+
+extension LinearRingCoordinates {
+	
+	public init(from decoder: Decoder) throws {
+		let container = try decoder.singleValueContainer()
+		
+		let coordinates = try container.decode(Self.RawValue.self)
+		
+		try self.init(rawValue: coordinates)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.singleValueContainer()
+		
+		try container.encode(self.rawValue)
+	}
+	
+}
+
+fileprivate enum SingleGeometryCodingKeys: String, CodingKey {
+	case geoJSONType = "type"
+	case coordinates, bbox
+}
+
+extension SingleGeometry {
+	
+	public init(from decoder: Decoder) throws {
+		let container = try decoder.container(keyedBy: SingleGeometryCodingKeys.self)
+		
+		let type = try container.decode(GeoJSON.`Type`.self, forKey: .geoJSONType)
+		guard type == Self.geoJSONType else {
+			throw DecodingError.typeMismatch(Self.self, DecodingError.Context(
+				codingPath: container.codingPath,
+				debugDescription: "Found GeoJSON type '\(type.rawValue)'"
+			))
+		}
+		
+		let coordinates = try container.decode(Self.Coordinates.self, forKey: .coordinates)
+		
+		self.init(coordinates: coordinates)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.container(keyedBy: SingleGeometryCodingKeys.self)
+		
+		try container.encode(Self.geoJSONType, forKey: .geoJSONType)
+		try container.encode(self.coordinates, forKey: .coordinates)
+		try container.encode(self.bbox, forKey: .bbox)
+	}
+	
+}
+
+fileprivate enum GeometryCollectionCodingKeys: String, CodingKey {
+	case geoJSONType = "type"
+	case geometries, bbox
+}
+
+extension GeometryCollection {
+	
+	public init(from decoder: Decoder) throws {
+		let container = try decoder.container(keyedBy: GeometryCollectionCodingKeys.self)
+		
+		let type = try container.decode(GeoJSON.`Type`.self, forKey: .geoJSONType)
+		guard type == Self.geoJSONType else {
+			throw DecodingError.typeMismatch(Self.self, DecodingError.Context(
+				codingPath: container.codingPath,
+				debugDescription: "Found GeoJSON type '\(type.rawValue)'"
+			))
+		}
+		
+		let geometries = try container.decode([AnyGeometry].self, forKey: .geometries)
+		
+		self.init(geometries: geometries)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.container(keyedBy: GeometryCollectionCodingKeys.self)
+		
+		try container.encode(Self.geoJSONType, forKey: .geoJSONType)
+		try container.encode(self.geometries, forKey: .geometries)
+		try container.encode(self.bbox, forKey: .bbox)
+	}
+	
+}
+
+fileprivate enum AnyGeometryCodingKeys: String, CodingKey {
+	case geoJSONType = "type"
+}
+
+extension AnyGeometry {
+	
+	public init(from decoder: Decoder) throws {
+		let typeContainer = try decoder.container(keyedBy: SingleGeometryCodingKeys.self)
+		let type = try typeContainer.decode(GeoJSON.`Type`.Geometry.self, forKey: .geoJSONType)
+		
+		let container = try decoder.singleValueContainer()
+		
+		// TODO: Fix 2D/3D performance by checking the number of values in `bbox`
+		switch type {
+		case .geometryCollection:
+			let geometryCollection = try container.decode(GeometryCollection.self)
+			self = .geometryCollection(geometryCollection)
+		case .point:
+			do {
+				let point3D = try container.decode(Point3D.self)
+				self = .point3D(point3D)
+			} catch {
+				let point2D = try container.decode(Point2D.self)
+				self = .point2D(point2D)
+			}
+		case .multiPoint:
+			do {
+				let multiPoint3D = try container.decode(MultiPoint3D.self)
+				self = .multiPoint3D(multiPoint3D)
+			} catch {
+				let multiPoint2D = try container.decode(MultiPoint2D.self)
+				self = .multiPoint2D(multiPoint2D)
+			}
+		case .lineString:
+			do {
+				let lineString3D = try container.decode(LineString3D.self)
+				self = .lineString3D(lineString3D)
+			} catch {
+				let lineString2D = try container.decode(LineString2D.self)
+				self = .lineString2D(lineString2D)
+			}
+		case .multiLineString:
+			do {
+				let multiLineString3D = try container.decode(MultiLineString3D.self)
+				self = .multiLineString3D(multiLineString3D)
+			} catch {
+				let multiLineString2D = try container.decode(MultiLineString2D.self)
+				self = .multiLineString2D(multiLineString2D)
+			}
+		case .polygon:
+			do {
+				let polygon3D = try container.decode(Polygon3D.self)
+				self = .polygon3D(polygon3D)
+			} catch {
+				let polygon2D = try container.decode(Polygon2D.self)
+				self = .polygon2D(polygon2D)
+			}
+		case .multiPolygon:
+			do {
+				let multiPolygon3D = try container.decode(MultiPolygon3D.self)
+				self = .multiPolygon3D(multiPolygon3D)
+			} catch {
+				let multiPolygon2D = try container.decode(MultiPolygon2D.self)
+				self = .multiPolygon2D(multiPolygon2D)
+			}
+		}
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.singleValueContainer()
+		
+		switch self {
+		case .geometryCollection(let geometryCollection):
+			try container.encode(geometryCollection)
+			
+		case .point2D(let point2D):
+			try container.encode(point2D)
+		case .multiPoint2D(let multiPoint2D):
+			try container.encode(multiPoint2D)
+		case .lineString2D(let lineString2D):
+			try container.encode(lineString2D)
+		case .multiLineString2D(let multiLineString2D):
+			try container.encode(multiLineString2D)
+		case .polygon2D(let polygon2D):
+			try container.encode(polygon2D)
+		case .multiPolygon2D(let multiPolygon2D):
+			try container.encode(multiPolygon2D)
+			
+		case .point3D(let point3D):
+			try container.encode(point3D)
+		case .multiPoint3D(let multiPoint3D):
+			try container.encode(multiPoint3D)
+		case .lineString3D(let lineString3D):
+			try container.encode(lineString3D)
+		case .multiLineString3D(let multiLineString3D):
+			try container.encode(multiLineString3D)
+		case .polygon3D(let polygon3D):
+			try container.encode(polygon3D)
+		case .multiPolygon3D(let multiPolygon3D):
+			try container.encode(multiPolygon3D)
+		}
+	}
+	
+}
+
+extension BoundingBox2D {
+	
+	public init(from decoder: Decoder) throws {
+		var container = try decoder.unkeyedContainer()
+		
+		let westLongitude = try container.decode(Longitude.self)
+		let southLatitude = try container.decode(Latitude.self)
+		let eastLongitude = try container.decode(Longitude.self)
+		let northLatitude = try container.decode(Latitude.self)
+		
+		self.init(
+			southWest: Coordinate2D(latitude: southLatitude, longitude: westLongitude),
+			northEast: Coordinate2D(latitude: northLatitude, longitude: eastLongitude)
+		)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.unkeyedContainer()
+		
+		try container.encode(self.westLongitude)
+		try container.encode(self.southLatitude)
+		try container.encode(self.eastLongitude)
+		try container.encode(self.northLatitude)
+	}
+	
+}
+
+extension BoundingBox3D {
+	
+	public init(from decoder: Decoder) throws {
+		var container = try decoder.unkeyedContainer()
+		
+		let westLongitude = try container.decode(Longitude.self)
+		let southLatitude = try container.decode(Latitude.self)
+		let lowAltitude   = try container.decode(Altitude.self)
+		let eastLongitude = try container.decode(Longitude.self)
+		let northLatitude = try container.decode(Latitude.self)
+		let highAltitude  = try container.decode(Altitude.self)
+		
+		self.init(
+			southWestLow: Coordinate3D(
+				latitude: southLatitude,
+				longitude: westLongitude,
+				altitude: lowAltitude
+			),
+			northEastHigh: Coordinate3D(
+				latitude: northLatitude,
+				longitude: eastLongitude,
+				altitude: highAltitude
+			)
+		)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.unkeyedContainer()
+		
+		try container.encode(self.twoDimensions.westLongitude)
+		try container.encode(self.twoDimensions.southLatitude)
+		try container.encode(self.lowAltitude)
+		try container.encode(self.twoDimensions.eastLongitude)
+		try container.encode(self.twoDimensions.northLatitude)
+		try container.encode(self.highAltitude)
+	}
+	
+}
+
+extension AnyBoundingBox {
+	
+	public init(from decoder: Decoder) throws {
+		let container = try decoder.singleValueContainer()
+		
+		do {
+			let boundingBox3D = try container.decode(BoundingBox3D.self)
+			self = .threeDimensions(boundingBox3D)
+		} catch {
+			let boundingBox2D = try container.decode(BoundingBox2D.self)
+			self = .twoDimensions(boundingBox2D)
+		}
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.singleValueContainer()
+		
+		switch self {
+		case .twoDimensions(let boundingBox2D):
+			try container.encode(boundingBox2D)
+		case .threeDimensions(let boundingBox3D):
+			try container.encode(boundingBox3D)
+		}
+	}
+	
+}
+
+fileprivate enum FeatureCodingKeys: String, CodingKey {
+	case geoJSONType = "type"
+	case id, geometry, properties, bbox
+}
+
+extension Feature {
+	
+	public init(from decoder: Decoder) throws {
+		print(String(describing: ID.self))
+		let container = try decoder.container(keyedBy: FeatureCodingKeys.self)
+		
+		let type = try container.decode(GeoJSON.`Type`.self, forKey: .geoJSONType)
+		guard type == Self.geoJSONType else {
+			throw DecodingError.typeMismatch(Self.self, DecodingError.Context(
+				codingPath: container.codingPath,
+				debugDescription: "Found GeoJSON type '\(type.rawValue)'"
+			))
+		}
+		
+		let id = try container.decodeIfPresent(ID.self, forKey: .id)
+		let geometry = try container.decodeIfPresent(Geometry.self, forKey: .geometry)
+		let properties = try container.decode(Properties.self, forKey: .properties)
+		
+		self.init(id: id, geometry: geometry, properties: properties)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.container(keyedBy: FeatureCodingKeys.self)
+		
+		try container.encode(Self.geoJSONType, forKey: .geoJSONType)
+		try container.encodeIfPresent(self.id, forKey: .id)
+		try container.encodeIfPresent(self.geometry, forKey: .geometry)
+		try container.encode(self.properties, forKey: .properties)
+		// TODO: Create GeoJSONEncoder that allows setting "export bboxes" to a boolean value
+		try container.encodeIfPresent(self.bbox, forKey: .bbox)
+	}
+	
+}
+
+fileprivate enum FeatureCollectionCodingKeys: String, CodingKey {
+	case geoJSONType = "type"
+	case features, bbox
+}
+
+extension FeatureCollection {
+	
+	public init(from decoder: Decoder) throws {
+		let container = try decoder.container(keyedBy: FeatureCollectionCodingKeys.self)
+		
+		let type = try container.decode(GeoJSON.`Type`.self, forKey: .geoJSONType)
+		guard type == Self.geoJSONType else {
+			throw DecodingError.typeMismatch(Self.self, DecodingError.Context(
+				codingPath: container.codingPath,
+				debugDescription: "Found GeoJSON type '\(type.rawValue)'"
+			))
+		}
+		
+		let features = try container.decodeIfPresent(
+			[Feature<ID, Geometry, Properties>].self,
+			forKey: .features
+		) ?? []
+		
+		self.init(features: features)
+	}
+	
+	public func encode(to encoder: Encoder) throws {
+		var container = encoder.container(keyedBy: FeatureCollectionCodingKeys.self)
+		
+		try container.encode(Self.geoJSONType, forKey: .geoJSONType)
+		try container.encodeIfPresent(self.features, forKey: .features)
+		// TODO: Create GeoJSONEncoder that allows setting "export bboxes" to a boolean value
+		try container.encodeIfPresent(self.bbox, forKey: .bbox)
+	}
+	
+}
diff --git a/Sources/GeoJSON/GeoJSON.docc/Documentation.md b/Sources/GeoJSON/GeoJSON.docc/Documentation.md
new file mode 100644
index 0000000..769ce58
--- /dev/null
+++ b/Sources/GeoJSON/GeoJSON.docc/Documentation.md
@@ -0,0 +1,30 @@
+# ``GeoJSON``
+
+## Topics
+
+### Positions
+
+- ``Position``
+- ``Position2D``
+- ``Position3D``
+
+### Types
+
+- <doc:Types>
+
+### Objects
+
+- ``Object``
+- ``CodableObject``
+- <doc:Geometries>
+- ``Feature``
+- ``AnyFeature``
+- ``FeatureCollection``
+- ``AnyFeatureCollection``
+
+### Bounding Boxes
+
+- ``BoundingBox``
+- ``BoundingBox2D``
+- ``BoundingBox3D``
+- ``AnyBoundingBox``
diff --git a/Sources/GeoJSON/GeoJSON.docc/Geometries.md b/Sources/GeoJSON/GeoJSON.docc/Geometries.md
new file mode 100644
index 0000000..ec387f0
--- /dev/null
+++ b/Sources/GeoJSON/GeoJSON.docc/Geometries.md
@@ -0,0 +1,55 @@
+# Geometries
+
+## Topics
+
+### Base
+
+- ``Geometry``
+- ``CodableGeometry``
+- ``AnyGeometry``
+
+### Introduced concepts
+
+- ``SingleGeometry``
+- ``LinearRingCoordinates``
+- ``LinearRingError``
+
+### Point
+
+- ``Point``
+- ``Point2D``
+- ``Point3D``
+
+### MultiPoint
+
+- ``MultiPoint``
+- ``MultiPoint2D``
+- ``MultiPoint3D``
+
+### LineString
+
+- ``LineString``
+- ``LineString2D``
+- ``LineString3D``
+
+### MultiLineString
+
+- ``MultiLineString``
+- ``MultiLineString2D``
+- ``MultiLineString3D``
+
+### Polygon
+
+- ``Polygon``
+- ``Polygon2D``
+- ``Polygon3D``
+
+### MultiPolygon
+
+- ``MultiPolygon``
+- ``MultiPolygon2D``
+- ``MultiPolygon3D``
+
+### GeometryCollection
+
+- ``GeometryCollection``
diff --git a/Sources/GeoJSON/GeoJSON.docc/Types.md b/Sources/GeoJSON/GeoJSON.docc/Types.md
new file mode 100644
index 0000000..4573a73
--- /dev/null
+++ b/Sources/GeoJSON/GeoJSON.docc/Types.md
@@ -0,0 +1,20 @@
+# Types
+
+## Topics
+
+### Enums
+
+- ``Type``
+- ``Type/Geometry``
+
+### Cases
+
+- ``Type/Geometry/point``
+- ``Type/Geometry/multiPoint``
+- ``Type/Geometry/lineString``
+- ``Type/Geometry/multiLineString``
+- ``Type/Geometry/polygon``
+- ``Type/Geometry/multiPolygon``
+- ``Type/Geometry/geometryCollection``
+- ``Type/feature``
+- ``Type/featureCollection``
diff --git a/Sources/GeoJSON/Geometries/GeometryCollection.swift b/Sources/GeoJSON/Geometries/GeometryCollection.swift
new file mode 100644
index 0000000..cc1fdc7
--- /dev/null
+++ b/Sources/GeoJSON/Geometries/GeometryCollection.swift
@@ -0,0 +1,24 @@
+//
+//  GeometryCollection.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+/// A [GeoJSON GeometryCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.8).
+public struct GeometryCollection: CodableGeometry {
+	
+	public static var geometryType: GeoJSON.`Type`.Geometry { .geometryCollection }
+	
+	public var _bbox: AnyBoundingBox { geometries.bbox }
+	
+	public var asAnyGeometry: AnyGeometry { .geometryCollection(self) }
+	
+	public var geometries: [AnyGeometry]
+	
+	public init(geometries: [AnyGeometry]) {
+		self.geometries = geometries
+	}
+	
+}
diff --git a/Sources/GeoJSON/Geometries/LineString.swift b/Sources/GeoJSON/Geometries/LineString.swift
new file mode 100644
index 0000000..1659814
--- /dev/null
+++ b/Sources/GeoJSON/Geometries/LineString.swift
@@ -0,0 +1,69 @@
+//
+//  LineString.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import NonEmpty
+
+/// A [GeoJSON LineString](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.4).
+public protocol LineString: SingleGeometry {
+	
+	associatedtype Position: GeoJSON.Position
+	associatedtype Coordinates = NonEmpty<NonEmpty<[Position]>>
+	
+}
+
+extension LineString {
+	
+	public static var geometryType: GeoJSON.`Type`.Geometry { .lineString }
+	
+}
+
+/// A ``LineString`` with ``Point2D``s.
+public struct LineString2D: LineString {
+	
+	public typealias Position = Position2D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .lineString2D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+	public init?(coordinates: [Position2D]) {
+		guard let coordinates = NonEmpty(rawValue: coordinates)
+			.flatMap(NonEmpty.init(rawValue:))
+		else { return nil }
+		
+		self.init(coordinates: coordinates)
+	}
+	
+}
+
+/// A ``LineString`` with ``Point3D``s.
+public struct LineString3D: LineString {
+	
+	public typealias Position = Position3D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .lineString3D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+	public init?(coordinates: [Position3D]) {
+		guard let coordinates = NonEmpty(rawValue: coordinates)
+			.flatMap(NonEmpty.init(rawValue:))
+		else { return nil }
+		
+		self.init(coordinates: coordinates)
+	}
+	
+}
diff --git a/Sources/GeoJSON/Geometries/MultiLineString.swift b/Sources/GeoJSON/Geometries/MultiLineString.swift
new file mode 100644
index 0000000..1eb6f8c
--- /dev/null
+++ b/Sources/GeoJSON/Geometries/MultiLineString.swift
@@ -0,0 +1,81 @@
+//
+//  MultiLineString.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import NonEmpty
+
+/// A [GeoJSON MultiLineString](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.5).
+public protocol MultiLineString: SingleGeometry {
+	
+	associatedtype LineString: GeoJSON.LineString
+	associatedtype Coordinates = [LineString.Coordinates]
+	
+}
+
+extension MultiLineString {
+	
+	public static var geometryType: GeoJSON.`Type`.Geometry { .multiLineString }
+	
+}
+
+/// A ``MultiLineString`` with ``Point2D``s.
+public struct MultiLineString2D: MultiLineString {
+	
+	public typealias LineString = LineString2D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .multiLineString2D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+	public init?(coordinates: [[Position2D]]) {
+		var coord1 = [LineString2D.Coordinates]()
+		
+		for coord2 in coordinates {
+			guard let coord3 = NonEmpty(rawValue: coord2)
+				.flatMap(NonEmpty.init(rawValue:))
+			else { return nil }
+			
+			coord1.append(coord3)
+		}
+		
+		self.init(coordinates: coord1)
+	}
+	
+}
+
+/// A ``MultiLineString`` with ``Point3D``s.
+public struct MultiLineString3D: MultiLineString {
+	
+	public typealias LineString = LineString3D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .multiLineString3D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+	public init?(coordinates: [[Position3D]]) {
+		var coord1 = [LineString3D.Coordinates]()
+		
+		for coord2 in coordinates {
+			guard let coord3 = NonEmpty(rawValue: coord2)
+				.flatMap(NonEmpty.init(rawValue:))
+			else { return nil }
+			
+			coord1.append(coord3)
+		}
+		
+		self.init(coordinates: coord1)
+	}
+	
+}
diff --git a/Sources/GeoJSON/Geometries/MultiPoint.swift b/Sources/GeoJSON/Geometries/MultiPoint.swift
new file mode 100644
index 0000000..381c87c
--- /dev/null
+++ b/Sources/GeoJSON/Geometries/MultiPoint.swift
@@ -0,0 +1,51 @@
+//
+//  MultiPoint.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+/// A [GeoJSON MultiPoint](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.3).
+public protocol MultiPoint: SingleGeometry {
+	
+	associatedtype Point: GeoJSON.Point
+	associatedtype Coordinates = [Point.Coordinates]
+	
+}
+
+extension MultiPoint {
+	
+	public static var geometryType: GeoJSON.`Type`.Geometry { .multiPoint }
+	
+}
+
+/// A ``MultiPoint`` with ``Point2D``s.
+public struct MultiPoint2D: MultiPoint {
+	
+	public typealias Point = Point2D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .multiPoint2D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+}
+
+/// A ``MultiPoint`` with ``Point3D``s.
+public struct MultiPoint3D: MultiPoint {
+	
+	public typealias Point = Point3D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .multiPoint3D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+}
diff --git a/Sources/GeoJSON/Geometries/MultiPolygon.swift b/Sources/GeoJSON/Geometries/MultiPolygon.swift
new file mode 100644
index 0000000..bb477d2
--- /dev/null
+++ b/Sources/GeoJSON/Geometries/MultiPolygon.swift
@@ -0,0 +1,51 @@
+//
+//  MultiPolygon.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+/// A [GeoJSON MultiPolygon](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.7).
+public protocol MultiPolygon: SingleGeometry {
+	
+	associatedtype Polygon: GeoJSON.Polygon
+	associatedtype Coordinates = [Polygon.Coordinates]
+	
+}
+
+extension MultiPolygon {
+	
+	public static var geometryType: GeoJSON.`Type`.Geometry { .multiPolygon }
+	
+}
+
+/// A ``MultiPolygon`` with ``Point2D``s.
+public struct MultiPolygon2D: MultiPolygon {
+	
+	public typealias Polygon = Polygon2D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .multiPolygon2D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+}
+
+/// A ``MultiPolygon`` with ``Point3D``s.
+public struct MultiPolygon3D: MultiPolygon {
+	
+	public typealias Polygon = Polygon3D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .multiPolygon3D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+}
diff --git a/Sources/GeoJSON/Geometries/Point.swift b/Sources/GeoJSON/Geometries/Point.swift
new file mode 100644
index 0000000..968d013
--- /dev/null
+++ b/Sources/GeoJSON/Geometries/Point.swift
@@ -0,0 +1,51 @@
+//
+//  Point.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+/// A [GeoJSON Point](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.2).
+public protocol Point: SingleGeometry {
+	
+	associatedtype Position: GeoJSON.Position
+	associatedtype Coordinates = Position
+	
+}
+
+extension Point {
+	
+	public static var geometryType: GeoJSON.`Type`.Geometry { .point }
+	
+}
+
+/// A two-dimensional ``Point`` (with longitude and latitude).
+public struct Point2D: Point {
+	
+	public typealias Position = Position2D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .point2D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+}
+
+/// A three-dimensional ``Point`` (with longitude, latitude and altitude).
+public struct Point3D: Point {
+	
+	public typealias Position = Position3D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .point3D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+}
diff --git a/Sources/GeoJSON/Geometries/Polygon.swift b/Sources/GeoJSON/Geometries/Polygon.swift
new file mode 100644
index 0000000..c807cd8
--- /dev/null
+++ b/Sources/GeoJSON/Geometries/Polygon.swift
@@ -0,0 +1,93 @@
+//
+//  Polygon.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import NonEmpty
+import Turf
+
+/// A [GeoJSON Polygon](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6) / linear ring.
+public protocol Polygon: SingleGeometry {
+	
+	associatedtype Point: GeoJSON.Point
+	associatedtype Coordinates = [LinearRingCoordinates<Point>]
+	
+}
+
+extension Polygon {
+	
+	public static var geometryType: GeoJSON.`Type`.Geometry { .polygon }
+	
+}
+
+public struct LinearRingCoordinates<Point: GeoJSON.Point>: Boundable, Hashable, Codable {
+	
+	public typealias RawValue = NonEmpty<NonEmpty<NonEmpty<NonEmpty<[Point.Coordinates]>>>>
+	
+	public var rawValue: RawValue
+	
+	public var _bbox: RawValue.BoundingBox { rawValue.bbox }
+	
+	public init(rawValue: RawValue) throws {
+		guard rawValue.first == rawValue.last else {
+			throw LinearRingError.firstAndLastPositionsShouldBeEquivalent
+		}
+		self.rawValue = rawValue
+	}
+	
+	public init(elements: [Point.Coordinates]) throws {
+		guard let rawValue = NonEmpty(rawValue: elements)
+			.flatMap(NonEmpty.init(rawValue:))
+			.flatMap(NonEmpty.init(rawValue:))
+			.flatMap(NonEmpty.init(rawValue:))
+		else { throw LinearRingError.notEnoughPoints }
+		
+		try self.init(rawValue: rawValue)
+	}
+	
+}
+
+extension LinearRingCoordinates: ExpressibleByArrayLiteral {
+	
+	public init(arrayLiteral elements: Point.Coordinates...) {
+		do {
+			try self.init(elements: elements)
+		} catch {
+			fatalError("Array literal should contain at least 4 values.")
+		}
+	}
+	
+}
+
+/// A ``Polygon`` with ``Point2D``s.
+public struct Polygon2D: Polygon {
+	
+	public typealias Point = Point2D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .polygon2D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+}
+
+/// A ``Polygon`` with ``Point3D``s.
+public struct Polygon3D: Polygon {
+	
+	public typealias Point = Point3D
+	
+	public var coordinates: Coordinates
+	
+	public var asAnyGeometry: AnyGeometry { .polygon3D(self) }
+	
+	public init(coordinates: Coordinates) {
+		self.coordinates = coordinates
+	}
+	
+}
diff --git a/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift b/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift
new file mode 100644
index 0000000..3cc1e05
--- /dev/null
+++ b/Sources/GeoJSON/Helpers/GeoJSON+Boundable.swift
@@ -0,0 +1,18 @@
+//
+//  GeoJSON+Boundable.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 05/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import NonEmpty
+import Turf
+
+extension NonEmpty: Boundable where Collection: Hashable, Element: Boundable {
+	
+	public var _bbox: Element.BoundingBox {
+		self.reduce(nil, { $0.union($1.bbox) }) ?? .zero
+	}
+	
+}
diff --git a/Sources/GeoJSON/Helpers/NonEmpty+NonEmpty.swift b/Sources/GeoJSON/Helpers/NonEmpty+NonEmpty.swift
new file mode 100644
index 0000000..6937c8a
--- /dev/null
+++ b/Sources/GeoJSON/Helpers/NonEmpty+NonEmpty.swift
@@ -0,0 +1,39 @@
+//
+//  NonEmpty+NonEmpty.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 07/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import NonEmpty
+
+extension NonEmpty {
+	
+	/// `NonEmpty.init(rawValue:): 	(C) -> NonEmpty<C>?`
+	/// `I need: 					(C) -> NonEmpty<NonEmpty<C>>?`
+	
+	static func case2<C: Swift.Collection>(_ collection: C) -> NonEmpty<NonEmpty<C>>? {
+		if let collection: NonEmpty<C> = NonEmpty<C>(rawValue: collection) {
+			return NonEmpty<NonEmpty<C>>(rawValue: collection)
+		} else {
+			return nil
+		}
+	}
+	
+	init?<C: Swift.Collection>(nested rawValue: C) where Collection == NonEmpty<C> {
+		if let collection: NonEmpty<C> = NonEmpty<C>(rawValue: rawValue) {
+			self.init(rawValue: collection)
+		} else {
+			return nil
+		}
+	}
+	
+	init?<C: Swift.Collection>(nestedCollection rawValue: C) where Collection == NonEmpty<NonEmpty<C>> {
+		guard let a = NonEmpty<C>(rawValue: rawValue) else { return nil }
+		guard let b = NonEmpty<NonEmpty<C>>(rawValue: a) else { return nil }
+		
+		self.init(rawValue: b)
+	}
+	
+}
diff --git a/Sources/GeoJSON/Helpers/NonID.swift b/Sources/GeoJSON/Helpers/NonID.swift
new file mode 100644
index 0000000..3d8a2ec
--- /dev/null
+++ b/Sources/GeoJSON/Helpers/NonID.swift
@@ -0,0 +1,9 @@
+//
+//  NonID.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 09/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+public typealias NonID = Bool
diff --git a/Sources/GeoJSON/Objects/Feature.swift b/Sources/GeoJSON/Objects/Feature.swift
new file mode 100644
index 0000000..a076c27
--- /dev/null
+++ b/Sources/GeoJSON/Objects/Feature.swift
@@ -0,0 +1,60 @@
+//
+//  Feature.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+/// A [GeoJSON Feature](https://datatracker.ietf.org/doc/html/rfc7946#section-3.2).
+public struct Feature<
+	ID: Codable,
+	Geometry: GeoJSON.Geometry & Codable,
+	Properties: Codable
+>: CodableObject {
+	
+	public static var geoJSONType: GeoJSON.`Type` { .feature }
+	
+	public var bbox: Geometry.BoundingBox? { geometry?.bbox }
+	
+	public var id: ID?
+	public var geometry: Geometry?
+	/// The `"properties"` field of a [GeoJSON Feature](https://datatracker.ietf.org/doc/html/rfc7946#section-3.2).
+	public var properties: Properties
+	
+	public init(id: ID?, geometry: Geometry?, properties: Properties) {
+		self.id = id
+		self.geometry = geometry
+		self.properties = properties
+	}
+	
+	public init(geometry: Geometry?, properties: Properties) where ID == NonID {
+		self.id = nil
+		self.geometry = geometry
+		self.properties = properties
+	}
+	
+}
+
+extension Feature: Identifiable where ID: Hashable {}
+extension Feature: Equatable where ID: Equatable, Properties: Equatable {
+	
+	public static func == (lhs: Self, rhs: Self) -> Bool {
+		return lhs.id == rhs.id
+		&& lhs.geometry == rhs.geometry
+		&& lhs.properties == rhs.properties
+	}
+	
+}
+extension Feature: Hashable where ID: Hashable, Properties: Hashable {
+	
+	public func hash(into hasher: inout Hasher) {
+		hasher.combine(id)
+		hasher.combine(geometry)
+		hasher.combine(properties)
+	}
+	
+}
+
+/// A (half) type-erased ``Feature``.
+public typealias AnyFeature<Properties: Codable> = Feature<NonID, AnyGeometry, Properties>
diff --git a/Sources/GeoJSON/Objects/FeatureCollection.swift b/Sources/GeoJSON/Objects/FeatureCollection.swift
new file mode 100644
index 0000000..bdc26ff
--- /dev/null
+++ b/Sources/GeoJSON/Objects/FeatureCollection.swift
@@ -0,0 +1,45 @@
+//
+//  FeatureCollection.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 07/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+/// A [GeoJSON FeatureCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.3).
+public struct FeatureCollection<
+	ID: Codable,
+	Geometry: GeoJSON.Geometry & Codable,
+	Properties: Codable
+>: CodableObject {
+	
+	public static var geoJSONType: GeoJSON.`Type` { .featureCollection }
+	
+	public var features: [Feature<ID, Geometry, Properties>]
+	
+	public var bbox: AnyBoundingBox? {
+		features.compactMap(\.bbox).reduce(nil, { $0.union($1.asAny) })
+	}
+	
+}
+
+extension FeatureCollection: Equatable where ID: Equatable, Properties: Equatable {
+	
+	public static func == (lhs: Self, rhs: Self) -> Bool {
+		return lhs.features == rhs.features
+	}
+	
+}
+
+extension FeatureCollection: Hashable where ID: Hashable, Properties: Hashable {
+	
+	public func hash(into hasher: inout Hasher) {
+		hasher.combine(features)
+	}
+	
+}
+
+/// A (half) type-erased ``FeatureCollection``.
+public typealias AnyFeatureCollection<
+	Properties: Codable
+> = FeatureCollection<NonID, AnyGeometry, Properties>
diff --git a/Sources/GeoJSON/Objects/Geometry.swift b/Sources/GeoJSON/Objects/Geometry.swift
new file mode 100644
index 0000000..fbcfab2
--- /dev/null
+++ b/Sources/GeoJSON/Objects/Geometry.swift
@@ -0,0 +1,114 @@
+//
+//  Geometry.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import Turf
+
+/// A [GeoJSON Geometry](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1).
+public protocol Geometry: GeoJSON.Object, Boundable, Hashable
+where BoundingBox: GeoJSON.BoundingBox
+{
+	
+	/// This geometry, but type-erased.
+	var asAnyGeometry: AnyGeometry { get }
+	
+}
+
+public protocol CodableGeometry: Geometry, CodableObject {
+	
+	/// The GeoJSON type of this geometry.
+	static var geometryType: GeoJSON.`Type`.Geometry { get }
+	
+}
+
+extension CodableGeometry {
+	
+	public static var geoJSONType: GeoJSON.`Type` { .geometry(Self.geometryType) }
+	
+}
+
+/// A single [GeoJSON Geometry](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1)
+/// (not a [GeometryCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.8)).
+public protocol SingleGeometry: CodableGeometry {
+	
+	associatedtype Coordinates: Boundable & Hashable & Codable
+	associatedtype BoundingBox = Coordinates.BoundingBox
+	
+	var coordinates: Coordinates { get set }
+	
+	init(coordinates: Coordinates)
+	
+}
+
+extension SingleGeometry {
+	
+	public var _bbox: Coordinates.BoundingBox { coordinates.bbox }
+	
+}
+
+/// A type-erased ``Geometry``.
+public enum AnyGeometry: Geometry, Hashable, Codable {
+	
+	case geometryCollection(GeometryCollection)
+	
+	case point2D(Point2D)
+	case multiPoint2D(MultiPoint2D)
+	case lineString2D(LineString2D)
+	case multiLineString2D(MultiLineString2D)
+	case polygon2D(Polygon2D)
+	case multiPolygon2D(MultiPolygon2D)
+	
+	case point3D(Point3D)
+	case multiPoint3D(MultiPoint3D)
+	case lineString3D(LineString3D)
+	case multiLineString3D(MultiLineString3D)
+	case polygon3D(Polygon3D)
+	case multiPolygon3D(MultiPolygon3D)
+	
+//	public var geometryType: GeoJSON.`Type`.Geometry {
+//		switch self {
+//		case .geometryCollection(let geo): 	return geo.geometryType
+//
+//		case .point2D(let geo): 			return geo.geometryType
+//		case .multiPoint2D(let geo): 		return geo.geometryType
+//		case .lineString2D(let geo): 		return geo.geometryType
+//		case .multiLineString2D(let geo): 	return geo.geometryType
+//		case .polygon2D(let geo): 			return geo.geometryType
+//		case .multiPolygon2D(let geo): 		return geo.geometryType
+//
+//		case .point3D(let geo): 			return geo.geometryType
+//		case .multiPoint3D(let geo): 		return geo.geometryType
+//		case .lineString3D(let geo): 		return geo.geometryType
+//		case .multiLineString3D(let geo): 	return geo.geometryType
+//		case .polygon3D(let geo): 			return geo.geometryType
+//		case .multiPolygon3D(let geo): 		return geo.geometryType
+//		}
+//	}
+	
+	public var _bbox: AnyBoundingBox {
+		switch self {
+		case .geometryCollection(let geo): 	return geo.bbox
+			
+		case .point2D(let geo): 			return geo.bbox.asAny
+		case .multiPoint2D(let geo): 		return geo.bbox.asAny
+		case .lineString2D(let geo): 		return geo.bbox.asAny
+		case .multiLineString2D(let geo): 	return geo.bbox.asAny
+		case .polygon2D(let geo): 			return geo.bbox.asAny
+		case .multiPolygon2D(let geo): 		return geo.bbox.asAny
+			
+		case .point3D(let geo): 			return geo.bbox.asAny
+		case .multiPoint3D(let geo): 		return geo.bbox.asAny
+		case .lineString3D(let geo): 		return geo.bbox.asAny
+		case .multiLineString3D(let geo): 	return geo.bbox.asAny
+		case .polygon3D(let geo): 			return geo.bbox.asAny
+		case .multiPolygon3D(let geo): 		return geo.bbox.asAny
+		}
+	}
+	
+	public var asAnyGeometry: AnyGeometry { self }
+	
+}
diff --git a/Sources/GeoJSON/Objects/Object.swift b/Sources/GeoJSON/Objects/Object.swift
new file mode 100644
index 0000000..f2afa28
--- /dev/null
+++ b/Sources/GeoJSON/Objects/Object.swift
@@ -0,0 +1,18 @@
+//
+//  Object.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+/// A [GeoJSON Object](https://datatracker.ietf.org/doc/html/rfc7946#section-3).
+public protocol Object {}
+
+public protocol CodableObject: GeoJSON.Object, Codable {
+	
+	/// The [GeoJSON type](https://datatracker.ietf.org/doc/html/rfc7946#section-1.4) of this
+	/// [GeoJSON object](https://datatracker.ietf.org/doc/html/rfc7946#section-3).
+	static var geoJSONType: GeoJSON.`Type` { get }
+	
+}
diff --git a/Sources/GeoJSON/Position.swift b/Sources/GeoJSON/Position.swift
new file mode 100644
index 0000000..979f442
--- /dev/null
+++ b/Sources/GeoJSON/Position.swift
@@ -0,0 +1,23 @@
+//
+//  Position.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import GeoModels
+import Turf
+
+/// A [GeoJSON Position](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1).
+public protocol Position: Boundable {}
+
+/// A ``Position`` with two elements (longitude and latitude).
+public typealias Position2D = Coordinate2D
+
+extension Position2D: Position {}
+
+/// A ``Position`` with three elements (longitude, latitude and altitude).
+public typealias Position3D = Coordinate3D
+
+extension Position3D: Position {}
diff --git a/Sources/GeoJSON/Type.swift b/Sources/GeoJSON/Type.swift
new file mode 100644
index 0000000..d6ee41d
--- /dev/null
+++ b/Sources/GeoJSON/Type.swift
@@ -0,0 +1,53 @@
+//
+//  Type.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 07/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+/// A [GeoJSON type identifier](https://datatracker.ietf.org/doc/html/rfc7946#section-1.4).
+public enum `Type`: Hashable, Codable, RawRepresentable {
+	
+	/// A [GeoJSON geometry identifier](https://datatracker.ietf.org/doc/html/rfc7946#section-1.4).
+	public enum Geometry: String, Hashable, Codable {
+		case point = "Point"
+		case multiPoint = "MultiPoint"
+		case lineString = "LineString"
+		case multiLineString = "MultiLineString"
+		case polygon = "Polygon"
+		case multiPolygon = "MultiPolygon"
+		case geometryCollection = "GeometryCollection"
+	}
+	
+	case geometry(Geometry)
+	case feature
+	case featureCollection
+	
+	public var rawValue: String {
+		switch self {
+		case .geometry(let geometry):
+			return geometry.rawValue
+		case .feature:
+			return "Feature"
+		case .featureCollection:
+			return "FeatureCollection"
+		}
+	}
+	
+	public init?(rawValue: String) {
+		switch rawValue {
+		case "Feature":
+			self = .feature
+		case "FeatureCollection":
+			self = .featureCollection
+		default:
+			if let geometry = Geometry(rawValue: rawValue) {
+				self = .geometry(geometry)
+			} else {
+				return nil
+			}
+		}
+	}
+	
+}
diff --git a/Sources/GeoModels/2D/BoundingBox2D.swift b/Sources/GeoModels/2D/BoundingBox2D.swift
new file mode 100644
index 0000000..741df10
--- /dev/null
+++ b/Sources/GeoModels/2D/BoundingBox2D.swift
@@ -0,0 +1,153 @@
+//
+//  BoundingBox2D.swift
+//  SwiftGeo
+//
+//  Created by Rémi Bardon on 02/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+public struct BoundingBox2D: Hashable {
+	
+	public var southWest: Coordinate2D
+	public var width: Longitude
+	public var height: Latitude
+	
+	public var southLatitude: Latitude {
+		southWest.latitude
+	}
+	public var northLatitude: Latitude {
+		southLatitude + height
+	}
+	public var centerLatitude: Latitude {
+		southLatitude + (height / 2.0)
+	}
+	public var westLongitude: Longitude {
+		southWest.longitude
+	}
+	public var eastLongitude: Longitude {
+		let longitude = westLongitude + width
+		
+		if longitude > .halfRotation {
+			return longitude - .fullRotation
+		} else {
+			return longitude
+		}
+	}
+	public var centerLongitude: Longitude {
+		let longitude = westLongitude + (width / 2.0)
+		
+		if longitude > .halfRotation {
+			return longitude - .fullRotation
+		} else {
+			return longitude
+		}
+	}
+	
+	public var northEast: Coordinate2D {
+		Coordinate2D(latitude: northLatitude, longitude: eastLongitude)
+	}
+	public var northWest: Coordinate2D {
+		Coordinate2D(latitude: northLatitude, longitude: westLongitude)
+	}
+	public var southEast: Coordinate2D {
+		Coordinate2D(latitude: southLatitude, longitude: westLongitude)
+	}
+	public var center: Coordinate2D {
+		Coordinate2D(latitude: centerLatitude, longitude: centerLongitude)
+	}
+	
+	public var south: Coordinate2D {
+		southAtLongitude(centerLongitude)
+	}
+	public var north: Coordinate2D {
+		northAtLongitude(centerLongitude)
+	}
+	public var west: Coordinate2D {
+		westAtLatitude(centerLatitude)
+	}
+	public var east: Coordinate2D {
+		eastAtLatitude(centerLatitude)
+	}
+	
+	public var crosses180thMeridian: Bool {
+		westLongitude > eastLongitude
+	}
+	
+	public init(
+		southWest: Coordinate2D,
+		width: Longitude,
+		height: Latitude
+	) {
+		self.southWest = southWest
+		self.width = width
+		self.height = height
+	}
+	
+	public init(
+		southWest: Coordinate2D,
+		northEast: Coordinate2D
+	) {
+		self.init(
+			southWest: southWest,
+			width: northEast.longitude - southWest.longitude,
+			height: northEast.latitude - southWest.latitude
+		)
+	}
+	
+	public func southAtLongitude(_ longitude: Longitude) -> Coordinate2D {
+		Coordinate2D(latitude: northEast.latitude, longitude: longitude)
+	}
+	public func northAtLongitude(_ longitude: Longitude) -> Coordinate2D {
+		Coordinate2D(latitude: southWest.latitude, longitude: longitude)
+	}
+	public func westAtLatitude(_ latitude: Latitude) -> Coordinate2D {
+		Coordinate2D(latitude: latitude, longitude: southWest.longitude)
+	}
+	public func eastAtLatitude(_ latitude: Latitude) -> Coordinate2D {
+		Coordinate2D(latitude: latitude, longitude: northEast.longitude)
+	}
+	
+	public func offsetBy(dLat: Latitude = .zero, dLong: Longitude = .zero) -> BoundingBox2D {
+		Self.init(
+			southWest: southWest.offsetBy(dLat: dLat, dLong: dLong),
+			width: width,
+			height: height
+		)
+	}
+	public func offsetBy(dx: Coordinate2D.X = .zero, dy: Coordinate2D.Y = .zero) -> BoundingBox2D {
+		Self.init(
+			southWest: southWest.offsetBy(dx: dx, dy: dy),
+			width: width,
+			height: height
+		)
+	}
+	
+}
+
+extension BoundingBox2D: BoundingBox {
+	
+	public static var zero: BoundingBox2D {
+		Self.init(southWest: .zero, width: .zero, height: .zero)
+	}
+	
+	/// The union of bounding boxes gives a new bounding box that encloses the given two.
+	public func union(_ other: BoundingBox2D) -> BoundingBox2D {
+		Self.init(
+			southWest: Coordinate2D(
+				latitude: min(self.southLatitude, other.southLatitude),
+				longitude: min(self.westLongitude, other.westLongitude)
+			),
+			northEast: Coordinate2D(
+				latitude: max(self.northLatitude, other.northLatitude),
+				longitude: max(self.eastLongitude, other.eastLongitude)
+			)
+		)
+	}
+	
+}
+
+extension BoundingBox2D {
+	
+	public func union(_ bbox3d: BoundingBox3D) -> BoundingBox3D { bbox3d.union(self) }
+	
+}
diff --git a/Sources/GeoModels/3D/BoundingBox3D.swift b/Sources/GeoModels/3D/BoundingBox3D.swift
new file mode 100644
index 0000000..4c09a73
--- /dev/null
+++ b/Sources/GeoModels/3D/BoundingBox3D.swift
@@ -0,0 +1,121 @@
+//
+//  BoundingBox3D.swift
+//  SwiftGeo
+//
+//  Created by Rémi Bardon on 08/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+public struct BoundingBox3D: Hashable {
+	
+	public var twoDimensions: BoundingBox2D
+	public var lowAltitude: Altitude
+	public var zHeight: Altitude
+	
+	public var highAltitude: Altitude {
+		lowAltitude + zHeight
+	}
+	public var centerAltitude: Altitude {
+		lowAltitude + (zHeight / 2.0)
+	}
+	
+	public var southWestLow: Coordinate3D {
+		Coordinate3D(twoDimensions.southWest, altitude: lowAltitude)
+	}
+	public var northEastHigh: Coordinate3D {
+		Coordinate3D(twoDimensions.northEast, altitude: highAltitude)
+	}
+	public var center: Coordinate3D {
+		Coordinate3D(twoDimensions.center, altitude: centerAltitude)
+	}
+	
+	public var crosses180thMeridian: Bool {
+		twoDimensions.crosses180thMeridian
+	}
+	
+	public init(_ boundingBox2d: BoundingBox2D, lowAltitude: Altitude, zHeight: Altitude) {
+		self.twoDimensions = boundingBox2d
+		self.lowAltitude = lowAltitude
+		self.zHeight = zHeight
+	}
+	
+	public init(
+		southWestLow: Coordinate3D,
+		width: Longitude,
+		height: Latitude,
+		zHeight: Altitude
+	) {
+		self.init(
+			BoundingBox2D(southWest: southWestLow.twoDimensions, width: width, height: height),
+			lowAltitude: southWestLow.altitude,
+			zHeight: zHeight
+		)
+	}
+	
+	public init(
+		southWestLow: Coordinate3D,
+		northEastHigh: Coordinate3D
+	) {
+		self.init(
+			southWestLow: southWestLow,
+			width: northEastHigh.longitude - southWestLow.longitude,
+			height: northEastHigh.latitude - southWestLow.latitude,
+			zHeight: northEastHigh.altitude - southWestLow.altitude
+		)
+	}
+	
+	public func offsetBy(
+		dLat: Latitude = .zero,
+		dLong: Longitude = .zero,
+		dAlt: Altitude = .zero
+	) -> BoundingBox3D {
+		Self.init(
+			twoDimensions.offsetBy(dLat: dLat, dLong: dLong),
+			lowAltitude: lowAltitude + dAlt,
+			zHeight: zHeight
+		)
+	}
+	public func offsetBy(
+		dx: Coordinate3D.X = .zero,
+		dy: Coordinate3D.Y = .zero,
+		dz: Coordinate3D.Z = .zero
+	) -> BoundingBox3D {
+		Self.init(
+			twoDimensions.offsetBy(dx: dx, dy: dy),
+			lowAltitude: lowAltitude + dz,
+			zHeight: zHeight
+		)
+	}
+	
+}
+
+extension BoundingBox3D: BoundingBox {
+	
+	public static var zero: BoundingBox3D {
+		BoundingBox3D(.zero, lowAltitude: .zero, zHeight: .zero)
+	}
+	
+	/// The union of bounding boxes gives a new bounding box that encloses the given two.
+	public func union(_ other: BoundingBox3D) -> BoundingBox3D {
+		BoundingBox3D(
+			southWestLow: Coordinate3D(
+				self.twoDimensions.union(other.twoDimensions).southWest,
+				altitude: min(self.lowAltitude, other.lowAltitude)
+			),
+			northEastHigh: Coordinate3D(
+				self.twoDimensions.union(other.twoDimensions).northEast,
+				altitude: max(self.highAltitude, other.highAltitude)
+			)
+		)
+	}
+	
+}
+
+extension BoundingBox3D {
+	
+	public func union(_ bbox2d: BoundingBox2D) -> BoundingBox3D {
+		let other = BoundingBox3D(bbox2d, lowAltitude: self.lowAltitude, zHeight: .zero)
+		return self.union(other)
+	}
+	
+}
diff --git a/Sources/GeoModels/BoundingBox.swift b/Sources/GeoModels/BoundingBox.swift
new file mode 100644
index 0000000..7d51e48
--- /dev/null
+++ b/Sources/GeoModels/BoundingBox.swift
@@ -0,0 +1,23 @@
+//
+//  BoundingBox.swift
+//  SwiftGeo
+//
+//  Created by Rémi Bardon on 04/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+public protocol BoundingBox: Hashable {
+	
+	static var zero: Self { get }
+	
+	func union(_ other: Self) -> Self
+	
+}
+
+extension Optional where Wrapped: BoundingBox {
+	
+	public func union(_ bbox: Wrapped) -> Wrapped {
+		self?.union(bbox) ?? bbox
+	}
+	
+}
diff --git a/Sources/Turf/BoundingBoxCache.swift b/Sources/Turf/BoundingBoxCache.swift
new file mode 100644
index 0000000..3a6e7cd
--- /dev/null
+++ b/Sources/Turf/BoundingBoxCache.swift
@@ -0,0 +1,41 @@
+//
+//  BoundingBoxCache.swift
+//  SwiftGeo
+//
+//  Created by Rémi Bardon on 10/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import GeoModels
+
+public class BoundingBoxCache {
+	
+	public static let shared = BoundingBoxCache()
+	
+	private var values = [AnyHashable: Any]()
+	
+	private init() {}
+	
+	internal func store<B: BoundingBox, K: Hashable>(_ value: B, forKey key: K) {
+		values[AnyHashable(key)] = value
+	}
+	
+	internal func get<B: BoundingBox, K: Hashable>(_ type: B.Type, forKey key: K) -> B? {
+		values[AnyHashable(key)] as? B
+	}
+	
+	public func bbox<B: Boundable & Hashable>(for boundable: B) -> B.BoundingBox {
+		if let cachedValue = self.get(B.BoundingBox.self, forKey: boundable) {
+			return cachedValue
+		} else {
+			let bbox = boundable._bbox
+			self.store(bbox, forKey: boundable)
+			return bbox
+		}
+	}
+	
+	public func removeCache<K: Hashable>(for key: K) {
+		values.removeValue(forKey: AnyHashable(key))
+	}
+	
+}
diff --git a/Sources/Turf/Turf.swift b/Sources/Turf/Turf.swift
new file mode 100644
index 0000000..68d10af
--- /dev/null
+++ b/Sources/Turf/Turf.swift
@@ -0,0 +1,150 @@
+//
+//  Turf.swift
+//  SwiftGeo
+//
+//  Created by Rémi Bardon on 20/04/2021.
+//  Copyright © 2021 Rémi Bardon. All rights reserved.
+//
+
+import Algorithms
+import GeoModels
+
+// FIXME: Fix formulae so they handle crossing the anti meridian
+
+/// Returns the [bounding box](https://en.wikipedia.org/wiki/Minimum_bounding_box) of a polygon.
+///
+/// - Warning: As stated in [section 3.1.6 of FRC 7946](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6),
+///   polygon ring MUST follow the right-hand rule for orientation (counterclockwise).
+//public func bbox(forPolygon coords: [Coordinate2D]) -> BoundingBox2D? {
+//	if coords.adjacentPairs().contains(where: { Line2D(start: $0, end: $1).crosses180thMeridian }) {
+//		let offsetCoords = coords.map(\.withPositiveLongitude)
+//
+//		return Turf.bbox(for: offsetCoords)
+//	} else {
+//		return Turf.bbox(for: coords)
+//	}
+//}
+
+/// Returns the minimum straight [bounding box](https://en.wikipedia.org/wiki/Minimum_bounding_box)
+/// enclosing the points.
+public func minimumBBox(for coords: [Coordinate2D]) -> BoundingBox2D? {
+	guard let bbox = Turf.bbox(for: coords) else { return nil }
+	
+	if bbox.width > .halfRotation {
+		let offsetCoords = coords.map(\.withPositiveLongitude)
+		
+		return Turf.bbox(for: offsetCoords)
+	} else {
+		return bbox
+	}
+}
+
+/// Returns the [bounding box](https://en.wikipedia.org/wiki/Minimum_bounding_box) enclosing the points.
+public func bbox<C: Collection>(for coords: C) -> BoundingBox2D? where C.Element == Coordinate2D {
+	guard let (south, north) = coords.map(\.latitude).minAndMax(),
+		  let (west, east) = coords.map(\.longitude).minAndMax()
+	else { return nil }
+	
+	return BoundingBox2D(
+		southWest: Coordinate2D(latitude: south, longitude: west),
+		northEast: Coordinate2D(latitude: north, longitude: east)
+	)
+}
+
+/// Returns the [bounding box](https://en.wikipedia.org/wiki/Minimum_bounding_box) enclosing the points.
+public func bbox<C: Collection>(for coords: C) -> BoundingBox3D? where C.Element == Coordinate3D {
+	guard let (south, north) = coords.map(\.latitude).minAndMax(),
+		  let (west, east) = coords.map(\.longitude).minAndMax(),
+		  let (low, high) = coords.map(\.altitude).minAndMax()
+	else { return nil }
+	
+	return BoundingBox3D(
+		southWestLow: Coordinate3D(latitude: south, longitude: west, altitude: low),
+		northEastHigh: Coordinate3D(latitude: north, longitude: east, altitude: high)
+	)
+}
+
+/// Returns the [bounding box](https://en.wikipedia.org/wiki/Minimum_bounding_box) enclosing all elements.
+public func bbox<T: Boundable, C: Collection>(for boundables: C) -> T.BoundingBox? where C.Element == T {
+	guard !boundables.isEmpty else { return nil }
+	
+	return boundables.reduce(nil, { $0.union($1.bbox) }) ?? .zero
+}
+
+/// Returns the absolute center of a polygon.
+///
+/// Ported from <https://github.com/Turfjs/turf/blob/84110709afda447a686ccdf55724af6ca755c1f8/packages/turf-center/index.ts#L36-L44>
+public func center(for coords: [Coordinate2D]) -> Coordinate2D? {
+	return bbox(for: coords)?.center
+}
+
+/// Returns the [center of mass](https://en.wikipedia.org/wiki/Center_of_mass) of a polygon.
+/// Used formula: [Centroid of Polygon](https://en.wikipedia.org/wiki/Centroid#Centroid_of_polygon)
+///
+/// Ported from <https://github.com/Turfjs/turf/blob/84110709afda447a686ccdf55724af6ca755c1f8/packages/turf-center-of-mass/index.ts#L32-L86>
+public func centerOfMass(for coords: [Coordinate2D]) -> Coordinate2D {
+	// First, we neutralize the feature (set it around coordinates [0,0]) to prevent rounding errors
+	// We take any point to translate all the points around 0
+	let centre: Coordinate2D = centroid(for: coords)
+	let translation: Coordinate2D = centre
+	var sx: Double = 0
+	var sy: Double = 0
+	var sArea: Double = 0
+	
+	let neutralizedPoints: [Coordinate2D] = coords.map { $0 - translation }
+	
+	for i in 0..<coords.count - 1 {
+		// pi is the current point
+		let pi: Coordinate2D = neutralizedPoints[i]
+		let xi: Double = pi.longitude.decimalDegrees
+		let yi: Double = pi.latitude.decimalDegrees
+		
+		// pj is the next point (pi+1)
+		let pj: Coordinate2D = neutralizedPoints[i + 1]
+		let xj: Double = pj.longitude.decimalDegrees
+		let yj: Double = pj.latitude.decimalDegrees
+		
+		// a is the common factor to compute the signed area and the final coordinates
+		let a: Double = xi * yj - xj * yi
+		
+		// sArea is the sum used to compute the signed area
+		sArea += a
+		
+		// sx and sy are the sums used to compute the final coordinates
+		sx += (xi + xj) * a
+		sy += (yi + yj) * a
+	}
+	
+	// Shape has no area: fallback on turf.centroid
+	if (sArea == 0) {
+		return centre
+	} else {
+		// Compute the signed area, and factorize 1/6A
+		let area: Double = sArea * 0.5
+		let areaFactor: Double = 1 / (6 * area)
+		
+		// Compute the final coordinates, adding back the values that have been neutralized
+		return Coordinate2D(
+			latitude: translation.latitude + Latitude(areaFactor * sy),
+			longitude: translation.longitude + Longitude(areaFactor * sx)
+		)
+	}
+}
+
+/// Calculates the centroid of a polygon using the mean of all vertices.
+///
+/// Ported from <https://github.com/Turfjs/turf/blob/84110709afda447a686ccdf55724af6ca755c1f8/packages/turf-centroid/index.ts#L21-L40>
+public func centroid(for coordinates: [Coordinate2D]) -> Coordinate2D {
+	var sumLongitude: Longitude = .zero
+	var sumLatitude: Latitude = .zero
+	
+	for coordinate in coordinates {
+		sumLongitude += coordinate.longitude
+		sumLatitude += coordinate.latitude
+	}
+	
+	return Coordinate2D(
+		latitude: sumLatitude / Latitude(coordinates.count),
+		longitude: sumLongitude / Longitude(coordinates.count)
+	)
+}
diff --git a/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift
new file mode 100644
index 0000000..bdbd869
--- /dev/null
+++ b/Tests/GeoJSONTests/GeoJSON+DecodableTests.swift
@@ -0,0 +1,397 @@
+//
+//  GeoJSON+DecodableTests.swift
+//  SwiftGeo
+//
+//  Created by Rémi Bardon on 07/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+@testable import GeoJSON
+import XCTest
+
+final class GeoJSONDecodableTests: XCTestCase {
+	
+	// MARK: Specification tests
+	
+	func testPosition2DDecode() throws {
+		let string: String = "[-1.55366,47.21881]"
+		let data: Data = try XCTUnwrap(string.data(using: .utf8))
+		
+		let position = try JSONDecoder().decode(Position2D.self, from: data)
+		
+		let expected: Position2D = .nantes
+		XCTAssertEqual(position, expected)
+	}
+	
+	func testPoint2DDecode() throws {
+		let string: String = [
+			"{",
+				"\"type\":\"Point\",",
+				"\"coordinates\":[-1.55366,47.21881]",
+			"}",
+		].joined()
+		let data: Data = try XCTUnwrap(string.data(using: .utf8))
+		
+		let point = try JSONDecoder().decode(Point2D.self, from: data)
+		
+		let expected: Point2D = Point2D(coordinates: .nantes)
+		XCTAssertEqual(point, expected)
+	}
+	
+	func testMultiPoint2DDecode() throws {
+		let string: String = [
+			"{",
+				"\"type\":\"MultiPoint\",",
+				"\"coordinates\":[",
+					"[-1.55366,47.21881]",
+				"]",
+			"}",
+		].joined()
+		let data: Data = try XCTUnwrap(string.data(using: .utf8))
+		
+		let multiPoint = try JSONDecoder().decode(MultiPoint2D.self, from: data)
+		
+		let expected: MultiPoint2D = MultiPoint2D(coordinates: [.nantes])
+		XCTAssertEqual(multiPoint, expected)
+	}
+	
+	func testLineString2DDecode() throws {
+		let string: String = [
+			"{",
+				"\"type\":\"LineString\",",
+				"\"coordinates\":[",
+					"[-1.55366,47.21881],",
+					"[2.3529,48.85719]",
+				"]",
+			"}",
+		].joined()
+		let data: Data = try XCTUnwrap(string.data(using: .utf8))
+		
+		let lineString = try JSONDecoder().decode(LineString2D.self, from: data)
+		
+		let expected: LineString2D = try XCTUnwrap(LineString2D(coordinates: [.nantes, .paris]))
+		XCTAssertEqual(lineString, expected)
+	}
+	
+	func testMultiLineString2DDecode() throws {
+		let string: String = [
+			"{",
+				"\"type\":\"MultiLineString\",",
+				"\"coordinates\":[",
+					"[",
+						"[-1.55366,47.21881],",
+						"[-0.58143,44.8378]",
+					"],",
+					"[",
+						"[2.3529,48.85719],",
+						"[5.36468,43.29868]",
+					"]",
+				"]",
+			"}",
+		].joined()
+		let data: Data = try XCTUnwrap(string.data(using: .utf8))
+		
+		let multiLineString = try JSONDecoder().decode(MultiLineString2D.self, from: data)
+		
+		let expected: MultiLineString2D = try XCTUnwrap(MultiLineString2D(coordinates: [
+			[.nantes, .bordeaux],
+			[.paris, .marseille],
+		]))
+		XCTAssertEqual(multiLineString, expected)
+	}
+	
+	func testPolygon2DDecode() throws {
+		let string = [
+			"{",
+				"\"type\":\"Polygon\",",
+				"\"coordinates\":[",
+					"[",
+						"[-1.55366,47.21881],",
+						"[-0.58143,44.8378],",
+						"[5.36468,43.29868],",
+						"[2.3529,48.85719],",
+						"[-1.55366,47.21881]",
+					"]",
+				"]",
+			"}",
+		].joined()
+		let data: Data = try XCTUnwrap(string.data(using: .utf8))
+		
+		let polygon = try JSONDecoder().decode(Polygon2D.self, from: data)
+		
+		let expected: Polygon2D = Polygon2D(coordinates: [
+			[.nantes, .bordeaux, .marseille, .paris, .nantes],
+		])
+		XCTAssertEqual(polygon, expected)
+	}
+	
+	func testMultiPolygon2DDecode() throws {
+		let string: String = [
+			"{",
+				"\"type\":\"MultiPolygon\",",
+				"\"coordinates\":[",
+					"[",
+						"[",
+							"[-1.55366,47.21881],",
+							"[-0.58143,44.8378],",
+							"[5.36468,43.29868],",
+							"[2.3529,48.85719],",
+							"[-1.55366,47.21881]",
+						"]",
+					"]",
+				"]",
+			"}",
+		].joined()
+		let data: Data = try XCTUnwrap(string.data(using: .utf8))
+		
+		let multiPolygon = try JSONDecoder().decode(MultiPolygon2D.self, from: data)
+		
+		let expected: MultiPolygon2D = try XCTUnwrap(MultiPolygon2D(coordinates: [
+			[
+				[.nantes, .bordeaux, .marseille, .paris, .nantes],
+			],
+		]))
+		XCTAssertEqual(multiPolygon, expected)
+	}
+	
+	func testFeature2DDecode() throws {
+		struct FeatureProperties: Hashable, Codable {}
+		
+		let string: String = [
+			"{",
+				"\"type\":\"Feature\",",
+				"\"geometry\":{",
+					"\"type\":\"Point\",",
+					"\"coordinates\":[-1.55366,47.21881]",
+				"},",
+				"\"properties\":{},",
+				"\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]",
+			"}",
+		].joined()
+		
+		let data: Data = try XCTUnwrap(string.data(using: .utf8))
+		let feature = try JSONDecoder().decode(Feature<NonID, Point2D, FeatureProperties>.self, from: data)
+		
+		let expected: Feature = Feature(
+			geometry: Point2D(coordinates: .nantes),
+			properties: FeatureProperties()
+		)
+		XCTAssertEqual(feature, expected)
+	}
+	
+	func testFeature2DWithIDDecode() throws {
+		struct FeatureProperties: Hashable, Codable {}
+		
+		let string: String = [
+			"{",
+				"\"type\":\"Feature\",",
+				"\"id\":\"feature_id\",",
+				"\"geometry\":{",
+					"\"type\":\"Point\",",
+					"\"coordinates\":[-1.55366,47.21881]",
+				"},",
+				"\"properties\":{},",
+				"\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]",
+			"}",
+		].joined()
+		
+		let data: Data = try XCTUnwrap(string.data(using: .utf8))
+		let feature = try JSONDecoder().decode(Feature<String, Point2D, FeatureProperties>.self, from: data)
+		
+		let expected: Feature = Feature(
+			id: "feature_id",
+			geometry: Point2D(coordinates: .nantes),
+			properties: FeatureProperties()
+		)
+		XCTAssertEqual(feature, expected)
+	}
+	
+	// MARK: Real-world use cases
+	
+	func testDecodeFeatureProperties() throws {
+		struct RealWorldProperties: Hashable, Codable {
+			let prop0: String
+		}
+		
+		let string = """
+		{
+			"type": "Feature",
+			"geometry": {
+				"type": "Point",
+				"coordinates": [102.0, 0.5]
+			},
+			"properties": {
+				"prop0": "value0"
+			}
+		}
+		"""
+		
+		let data: Data = try XCTUnwrap(string.data(using: .utf8))
+		let feature = try JSONDecoder().decode(AnyFeature<RealWorldProperties>.self, from: data)
+		
+		let expected: AnyFeature = AnyFeature(
+			geometry: .point2D(Point2D(coordinates: .init(latitude: 0.5, longitude: 102))),
+			properties: RealWorldProperties(prop0: "value0")
+		)
+		XCTAssertEqual(feature, expected)
+	}
+	
+	/// Example from [RFC 7946, section 1.5](https://datatracker.ietf.org/doc/html/rfc7946#section-1.5).
+	func testDecodeHeterogeneousFeatureCollection() throws {
+		enum HeterogeneousProperties: Hashable, Codable, CustomStringConvertible {
+			struct Properties1: Hashable, Codable, CustomStringConvertible {
+				let prop0: String
+				
+				var description: String {
+					"{prop0:\"\(prop0)\"}"
+				}
+			}
+			struct Properties2: Hashable, Codable, CustomStringConvertible {
+				let prop0: String
+				let prop1: Double
+				
+				var description: String {
+					"{prop0:\"\(prop0)\",prop1:\(prop1)}"
+				}
+			}
+			struct Properties3: Hashable, Codable, CustomStringConvertible {
+				struct Prop1: Hashable, Codable, CustomStringConvertible {
+					let this: String
+					
+					var description: String {
+						"{this:\"\(this)\"}"
+					}
+				}
+				
+				let prop0: String
+				let prop1: Prop1
+				
+				var description: String {
+					"{prop0:\"\(prop0)\",prop1:\(prop1)}"
+				}
+			}
+			
+			enum CodingKeys: String, CodingKey {
+				case prop0, prop1
+			}
+			
+			case type1(Properties1)
+			case type2(Properties2)
+			case type3(Properties3)
+			
+			var description: String {
+				switch self {
+				case .type1(let type1):
+					return type1.description
+				case .type2(let type2):
+					return type2.description
+				case .type3(let type3):
+					return type3.description
+				}
+			}
+			
+			init(from decoder: Decoder) throws {
+				let container = try decoder.container(keyedBy: CodingKeys.self)
+				
+				let prop0 = try container.decode(String.self, forKey: .prop0)
+				do {
+					do {
+						let prop1 = try container.decode(Double.self, forKey: .prop1)
+						self = .type2(.init(prop0: prop0, prop1: prop1))
+					} catch {
+						let prop1 = try container.decode(Properties3.Prop1.self, forKey: .prop1)
+						self = .type3(.init(prop0: prop0, prop1: prop1))
+					}
+				} catch {
+					self = .type1(.init(prop0: prop0))
+				}
+			}
+			
+			func encode(to encoder: Encoder) throws {
+				fatalError("Useless")
+			}
+		}
+		
+		let string = """
+		{
+			"type": "FeatureCollection",
+			"features": [{
+				"type": "Feature",
+				"geometry": {
+					"type": "Point",
+					"coordinates": [102.0, 0.5]
+				},
+				"properties": {
+					"prop0": "value0"
+				}
+			}, {
+				"type": "Feature",
+				"geometry": {
+					"type": "LineString",
+					"coordinates": [
+						[102.0, 0.0],
+						[103.0, 1.0],
+						[104.0, 0.0],
+						[105.0, 1.0]
+					]
+				},
+				"properties": {
+					"prop0": "value0",
+					"prop1": 0.0
+				}
+			}, {
+				"type": "Feature",
+				"geometry": {
+					"type": "Polygon",
+					"coordinates": [
+						[
+							[100.0, 0.0],
+							[101.0, 0.0],
+							[101.0, 1.0],
+							[100.0, 1.0],
+							[100.0, 0.0]
+						]
+					]
+				},
+				"properties": {
+					"prop0": "value0",
+					"prop1": {
+						"this": "that"
+					}
+				}
+			}]
+		}
+		"""
+		
+		let data: Data = try XCTUnwrap(string.data(using: .utf8))
+		let feature = try JSONDecoder().decode(AnyFeatureCollection<HeterogeneousProperties>.self, from: data)
+		
+		let expected: AnyFeatureCollection<HeterogeneousProperties> = AnyFeatureCollection(features: [
+			Feature(
+				geometry: .point2D(Point2D(coordinates: .init(latitude: 0.5, longitude: 102))),
+				properties: .type1(.init(prop0: "value0"))
+			),
+			Feature(
+				geometry: .lineString2D(.init(coordinates: [
+					.init(latitude: 0.0, longitude: 102.0),
+					.init(latitude: 1.0, longitude: 103.0),
+					.init(latitude: 0.0, longitude: 104.0),
+					.init(latitude: 1.0, longitude: 105.0)
+				])!),
+				properties: .type2(.init(prop0: "value0", prop1: 0))
+			),
+			Feature(
+				geometry: .polygon2D(.init(coordinates: .init(arrayLiteral: [
+					.init(latitude: 0.0, longitude: 100.0),
+					.init(latitude: 0.0, longitude: 101.0),
+					.init(latitude: 1.0, longitude: 101.0),
+					.init(latitude: 1.0, longitude: 100.0),
+					.init(latitude: 0.0, longitude: 100.0)
+				]))),
+				properties: .type3(.init(prop0: "value0", prop1: .init(this: "that")))
+			),
+		])
+		XCTAssertEqual(feature, expected)
+	}
+	
+}
diff --git a/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift
new file mode 100644
index 0000000..725f8fb
--- /dev/null
+++ b/Tests/GeoJSONTests/GeoJSON+EncodableTests.swift
@@ -0,0 +1,282 @@
+//
+//  GeoJSON+EncodableTests.swift
+//  SwiftGeo
+//
+//  Created by Rémi Bardon on 07/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+@testable import GeoJSON
+import XCTest
+
+final class GeoJSONEncodableTests: XCTestCase {
+	
+	func testPosition2DEncode() throws {
+		let data = try JSONEncoder().encode(Position2D.nantes)
+		let string = String(data: data, encoding: .utf8)
+		
+		XCTAssertEqual(string, "[-1.55366,47.21881]")
+	}
+	
+	func testPoint2DEncode() throws {
+		let point = Point2D(coordinates: .nantes)
+		let data = try JSONEncoder().encode(point)
+		let string = String(data: data, encoding: .utf8)
+		
+		XCTAssertEqual(string, [
+			"{",
+				"\"type\":\"Point\",",
+				"\"coordinates\":[-1.55366,47.21881],",
+				"\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]",
+			"}",
+		].joined())
+	}
+	
+	func testMultiPoint2DEncode() throws {
+		let multiPoint = MultiPoint2D(coordinates: [.nantes])
+		let data = try JSONEncoder().encode(multiPoint)
+		let string = String(data: data, encoding: .utf8)
+		
+		XCTAssertEqual(string, [
+			"{",
+				"\"type\":\"MultiPoint\",",
+				"\"coordinates\":[",
+					"[-1.55366,47.21881]",
+				"],",
+				"\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]",
+			"}",
+		].joined())
+	}
+	
+	func testLineString2DEncode() throws {
+		let lineString = try XCTUnwrap(LineString2D(coordinates: [.nantes, .paris]))
+		let data = try JSONEncoder().encode(lineString)
+		let string = String(data: data, encoding: .utf8)
+		
+		XCTAssertEqual(string, [
+			"{",
+				"\"type\":\"LineString\",",
+				"\"coordinates\":[",
+					"[-1.55366,47.21881],",
+					"[2.3529,48.85719]",
+				"],",
+				"\"bbox\":[-1.55366,47.21881,2.3529,48.85719]",
+			"}",
+		].joined())
+	}
+	
+	func testMultiLineString2DEncode() throws {
+		let multiLineString = try XCTUnwrap(MultiLineString2D(coordinates: [[.nantes, .bordeaux], [.paris, .marseille]]))
+		let data = try JSONEncoder().encode(multiLineString)
+		let string = String(data: data, encoding: .utf8)
+		
+		XCTAssertEqual(string, [
+			"{",
+				"\"type\":\"MultiLineString\",",
+				"\"coordinates\":[",
+					"[",
+						"[-1.55366,47.21881],",
+						"[-0.58143,44.8378]",
+					"],",
+					"[",
+						"[2.3529,48.85719],",
+						"[5.36468,43.29868]",
+					"]",
+				"],",
+				"\"bbox\":[-1.55366,43.29868,5.36468,48.85719]",
+			"}",
+		].joined())
+	}
+	
+	func testPolygon2DEncode() throws {
+		let polygon = try XCTUnwrap(Polygon2D(coordinates: [
+			[.nantes, .bordeaux, .marseille, .paris, .nantes],
+		]))
+		let data = try JSONEncoder().encode(polygon)
+		let string = String(data: data, encoding: .utf8)
+		
+		XCTAssertEqual(string, [
+			"{",
+				"\"type\":\"Polygon\",",
+				"\"coordinates\":[",
+					"[",
+						"[-1.55366,47.21881],",
+						"[-0.58143,44.8378],",
+						"[5.36468,43.29868],",
+						"[2.3529,48.85719],",
+						"[-1.55366,47.21881]",
+					"]",
+				"],",
+				"\"bbox\":[-1.55366,43.29868,5.36468,48.85719]",
+			"}",
+		].joined())
+	}
+	
+	// testPolygonNeeds4Points
+	// testPolygonIsClockwise
+	// testPolygon2DCrossingAntiMeridianEncode
+	
+	func testMultiPolygon2DEncode() throws {
+		let multiPolygon = try XCTUnwrap(MultiPolygon2D(coordinates: [
+			[
+				[.nantes, .bordeaux, .marseille, .paris, .nantes],
+			],
+		]))
+		let data = try JSONEncoder().encode(multiPolygon)
+		let string = String(data: data, encoding: .utf8)
+		
+		XCTAssertEqual(string, [
+			"{",
+				"\"type\":\"MultiPolygon\",",
+				"\"coordinates\":[",
+					"[",
+						"[",
+							"[-1.55366,47.21881],",
+							"[-0.58143,44.8378],",
+							"[5.36468,43.29868],",
+							"[2.3529,48.85719],",
+							"[-1.55366,47.21881]",
+						"]",
+					"]",
+				"],",
+				"\"bbox\":[-1.55366,43.29868,5.36468,48.85719]",
+			"}",
+		].joined())
+	}
+	
+	func testFeature2DEncode() throws {
+		struct FeatureProperties: Hashable, Codable {}
+		
+		let feature: Feature = Feature(
+			geometry: Point2D(coordinates: .nantes),
+			properties: FeatureProperties()
+		)
+		let data: Data = try JSONEncoder().encode(feature)
+		let string: String = try XCTUnwrap(String(data: data, encoding: .utf8))
+		
+		let expected: String = [
+			"{",
+				// For some reason, `"properties"` goes on top 🤷
+				"\"properties\":{},",
+				"\"type\":\"Feature\",",
+				"\"geometry\":{",
+					"\"type\":\"Point\",",
+					"\"coordinates\":[-1.55366,47.21881],",
+					"\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]",
+				"},",
+				"\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]",
+			"}",
+		].joined()
+		XCTAssertEqual(string, expected)
+	}
+	
+	func testFeature2DWithIDEncode() throws {
+		struct FeatureProperties: Hashable, Codable {}
+		
+		let feature: Feature = Feature(
+			id: "feature_id",
+			geometry: Point2D(coordinates: .nantes),
+			properties: FeatureProperties()
+		)
+		let data: Data = try JSONEncoder().encode(feature)
+		let string: String = try XCTUnwrap(String(data: data, encoding: .utf8))
+		
+		let expected: String = [
+			"{",
+				// For some reason, `"id"` goes here 🤷
+				"\"id\":\"feature_id\",",
+				// For some reason, `"properties"` goes here 🤷
+				"\"properties\":{},",
+				"\"type\":\"Feature\",",
+				"\"geometry\":{",
+					"\"type\":\"Point\",",
+					"\"coordinates\":[-1.55366,47.21881],",
+					"\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]",
+				"},",
+				"\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]",
+			"}",
+		].joined()
+		XCTAssertEqual(string, expected)
+	}
+	
+	func testFeatureCollectionOfGeometryCollectionEncode() throws {
+		struct FeatureProperties: Hashable, Codable {}
+		
+		let featureCollection: FeatureCollection = FeatureCollection(features: [
+			Feature(
+				geometry: GeometryCollection(geometries: [
+					.point2D(Point2D(coordinates: .nantes)),
+					.point2D(Point2D(coordinates: .bordeaux)),
+				]),
+				properties: FeatureProperties()
+			),
+			Feature(
+				geometry: GeometryCollection(geometries: [
+					.point2D(Point2D(coordinates: .paris)),
+					 .point2D(Point2D(coordinates: .marseille)),
+				]),
+				properties: FeatureProperties()
+			),
+		])
+		let data: Data = try JSONEncoder().encode(featureCollection)
+		let string: String = try XCTUnwrap(String(data: data, encoding: .utf8))
+		
+		let expected: String = [
+			"{",
+				"\"type\":\"FeatureCollection\",",
+				// For some reason, `"bbox"` goes here 🤷
+				"\"bbox\":[-1.55366,43.29868,5.36468,48.85719],",
+				"\"features\":[",
+					"{",
+						// For some reason, `"properties"` goes here 🤷
+						"\"properties\":{},",
+						"\"type\":\"Feature\",",
+						"\"geometry\":{",
+							"\"type\":\"GeometryCollection\",",
+							// For some reason, `"bbox"` goes here 🤷
+							"\"bbox\":[-1.55366,44.8378,-0.58143,47.21881],",
+							"\"geometries\":[",
+								"{",
+									"\"type\":\"Point\",",
+									"\"coordinates\":[-1.55366,47.21881],",
+									"\"bbox\":[-1.55366,47.21881,-1.55366,47.21881]",
+								"},",
+								"{",
+									"\"type\":\"Point\",",
+									"\"coordinates\":[-0.58143,44.8378],",
+									"\"bbox\":[-0.58143,44.8378,-0.58143,44.8378]",
+								"}",
+							"]",
+						"},",
+						"\"bbox\":[-1.55366,44.8378,-0.58143,47.21881]",
+					"},",
+					"{",
+						// For some reason, `"properties"` goes here 🤷
+						"\"properties\":{},",
+						"\"type\":\"Feature\",",
+						"\"geometry\":{",
+							"\"type\":\"GeometryCollection\",",
+							// For some reason, `"bbox"` goes here 🤷
+							"\"bbox\":[2.3529,43.29868,5.36468,48.85719],",
+							"\"geometries\":[",
+								"{",
+									"\"type\":\"Point\",",
+									"\"coordinates\":[2.3529,48.85719],",
+									"\"bbox\":[2.3529,48.85719,2.3529,48.85719]",
+								"},",
+								"{",
+									"\"type\":\"Point\",",
+									"\"coordinates\":[5.36468,43.29868],",
+									"\"bbox\":[5.36468,43.29868,5.36468,43.29868]",
+								"}",
+							"]",
+						"},",
+						"\"bbox\":[2.3529,43.29868,5.36468,48.85719]",
+					"}",
+				"]",
+			"}",
+		].joined()
+		XCTAssertEqual(string, expected)
+	}
+	
+}
diff --git a/Tests/GeoJSONTests/Position+TestValues.swift b/Tests/GeoJSONTests/Position+TestValues.swift
new file mode 100644
index 0000000..0d9a165
--- /dev/null
+++ b/Tests/GeoJSONTests/Position+TestValues.swift
@@ -0,0 +1,18 @@
+//
+//  Position+TestValues.swift
+//  GeoSwift
+//
+//  Created by Rémi Bardon on 07/02/2022.
+//  Copyright © 2022 Rémi Bardon. All rights reserved.
+//
+
+import GeoJSON
+
+extension Position2D {
+	
+	static var paris: Self     { Position2D(latitude:  48.85719, longitude:  2.35290) }
+	static var nantes: Self    { Position2D(latitude:  47.21881, longitude: -1.55366) }
+	static var bordeaux: Self  { Position2D(latitude:  44.83780, longitude: -0.58143) }
+	static var marseille: Self { Position2D(latitude:  43.29868, longitude:  5.36468) }
+	
+}