Lift is a Swift library for generating and extracting values into and out of JSON-like data structures. Lift was carefully designed to meet the following requirements:
- Use easy and intuitive syntax using subscripting.
- Be extendable for use with your custom types.
- Support of retroactive modeling/conformance.
- Do not enforce how to structure your data models.
- Be type safe and explicit about errors.
- Work with any key value structured data such as p-lists and user defaults.
- Provide detailed errors and support custom validation.
- Use value semantics for the
Jar
container.
Lift is simple, yet powerful. Let us see how to use it with a custom type:
struct User {
let name: String
let age: Int
}
Just conform to JarElement
to let Lift know how to transform your type:
extension User: JarElement {
init(jar: Jar) throws {
name = try jar["name"]^
age = try jar["age"]^
}
var jar: Jar {
return ["name": name, "age": age]
}
}
Then given some JSON, you can now construct a Jar
and extract users from it using the lift operator ^
:
let json = "[{\"name\": \"Adam\", \"age\": 25}, {\"name\": \"Eve\", \"age\": 20}]"
let jar = try Jar(json: json)
var users: [User] = try jar^
And it is as easy to move your model values back to JSON:
users.append(User(name: "Junior", age: 2))
let newJson = try String(json: Jar(users), prettyPrinted: true)
Lift will even work with other JSON-like structured data such as p-lists and UserDefaults
:
let users: [User] = try UserDefaults.standard["users"]^
Check the Usage section for more information and examples.
- Xcode
9.3+
- Swift 4.1
- Platforms:
- iOS
9.0+
- macOS
10.11+
- tvOS
9.0+
- watchOS
2.0+
- Linux
- iOS
github "iZettle/Lift" >= 2.3
platform :ios, '9.0'
use_frameworks!
target 'Your App Target' do
pod 'Lift', '~> 2.3'
end
import PackageDescription
let package = Package(
name: "Your Package Name",
dependencies: [
.Package(url: "https://github.com/iZettle/Lift.git",
majorVersion: 2)
]
)
Swift 4 introduced Codable
with the "promise" to have solved working with JSON once and for all. And yes, many of the examples shown are just close to magic. But when your models start to diverge from the simple ones to ones mapping between model and JSON, the magic seems to go away. Now you are back to implementing everything yourself and this using a quite verbose API. The current version of Swift also lacks APIs for building and parsing JSON on the fly (not going through model objects) which is common when e.g. building and parsing network requests. Hence, we believe the demand for third party JSON libraries will still be there for some time to come.
Let us start out with a simple example of how to extract data from some key value structured data such as JSON:
let jar: Jar = ["name": "Lift", "version": 1.0]
let name: String = try jar["name"]^
let version: Double = try jar["version"]^
Jar
is Lift's container of heterogenous values. In this example it holds a dictionary. The operator ^
(called the lift operator) is used to extract values out of the jar container. Because the jar typically holds values that are not known at compile time, extracting them might fail. This might happen if the value is missing, if the value is not of the expected type, or if some other validation is failing. This is why you will always see a try
in the presence of the lift operator ^
.
As mentioned, JSON does not always come in the form of a dictionary (key-values), but could also be simple primitive types or arrays of other JSON objects:
let i: Int = try Jar(1)^
let b: Bool = try Jar(true)^
let a: [Int] = try Jar([1, 2, 3])^
let jar: Jar = ["value": "lift"]
let s: String = try jar["value"]^
The ^
operator is overloaded to allow conforming types to either be extracted as the type itself or as an optional version of it. You can also extract an array or optional array of conforming types:
let i: Int = try jar^
let i: Int? = try jar^
let i: [Int] = try jar^
let i: [Int]? = try jar^
Jar
implements subscripting for keys and indices and also allows them to be nested:
let date: Date = try jar["payments"][3]["date"]^
jar["payments"][2]["date"] = Date()
Lift adds convenience initializers to construct a Jar
from JSON and back:
let json = "{ \"val\": 3, \"vals\": [ 1, 2 ] }"
let jar = try Jar(json: json)
let jsonString = try String(json: jar, prettyPrinted: true)
let jsonData = try Data(json: jar, prettyPrinted: false)
You could also handle the serialization yourself and just pass an Any
value:
let json: Any = ...
let jar = try Jar(checked: json) // Will validate when constructed - slower
let jar = Jar(unchecked: json) // Will lazily validate at access - faster
let any: Any = try jar.asAny()
To help creating JSON, Jar
implements several expressible by literal protocols so you can write code like:
func send(_ jar: Jar) { ... }
send(true)
send(1)
send(3.14)
send("Hello")
send(["val": 5])
send([1, 2, 3])
When Jar
can't be inferred by the complier, you can explicitly specify the type:
let jar: Jar = ["val": 5]
let jar: Jar = [1, 2, 3]
You can also build nested hierarchies:
let jar: Jar = [5, ["val": [1, 2]]]
And of course you can also build JSON from your custom types:
let jar: Jar = ["payment": payment, "date": date]
Because Jar
is a value type with value semantics, you can modify your Jar
value when declared as var
.
var jar = Jar()
jar["payment"] = payment
jar["date"] = Date()
var jar = Jar()
jar.append(payment)
jar.append(Date())
And if you need to modify your JSON before passing it on just make a copy:
func receive(jar: Jar) {
var jar = jar
jar["timeReceived"] = Date()
// ...
}
A Jar
is never bound to a specific type or value, hence it is always ok to change it:
var jar: Jar = true // jar holds a boolean
jar = [1, 2] // holds an array
jar["val"] = 1 // holds a dictionary
jar = 4711 // holds an integer
jar = ["val2": 2] // holds a dictionary
jar = "Hello" // holds a string
Lift supports working with arrays of primitive types:
var jar: Jar = [1, 2, 3]
jar[1] = 4
jar.append(5)
let val: Int = try jar[2]^
As well as arrays of your custom types:
var jar: Jar = [Payment(...), Payment(...), ...]
let payments: [Payment] = try jar^
jar[2] = Payment(...)
Sometimes the existence of a value is a requirement, sometimes it is optional.
let i: Int = try jar["val"]^ // Will throw if val is missing or not an Int
let i: Int? = try jar["val"]^ // Will return nil if val is missing or null, else throw if not an Int
let i: Int = try jar["val"]^ ?? 4711 // Will throw if val is present and is not an Int
For your convenience Lift treats a value set to JSON null the same as a missing value. But if you need to check for the presence of the actual null value itself you can write:
if let _: Null = try? jar["val"]^ {
//...
}
When building your JSON it is quite common that some values are optional:
let optional: Int? = nil
var jar: Jar = ["val": 1]
jar["optional"] = optional // -> {"val": 1}
It is also possible to add your optional inline:
var jar: Jar = ["val": 1, "optional": optional] // -> {"val": 1}
If you actually want a JSON null value you can use the constant null
:
var jar: Jar = ["val": 1, "optional": optional ?? null] // -> {"val": 1, "optional": null}
Sometimes parts of your JSON could hold the union of different kinds of valid types. Then you could test between the different variations you support:
let any: Any? = NSJSONSerialization...
let jar = try Jar(any) // any could be a dictionary, array or a primitive type
if let int: Int = try? jar^ {
// ...
} else if let ints: [Int] = try? jar^ {
// ...
} else if let int: Int = try? jar["value"]^ {
// ...
}
JSON also supports arrays of mixed types:
let jar = try Jar(any) // [ 1, [1, 2], { "val" : 3 } ] -- [Int, Array, Dictionary]
let int: Int = try jar[0]^
let array: [Int] = try jar[1]^
let dict: Jar = try jar[2]^
You sometimes need to transform the values extracted from a Jar
before using them. This might happen when you are working with types that cannot conform to JarRepresentable
, such as when using tuples:
typealias User = (name: String, age: Int)
let users: [User] = try (jar^ as [Jar]).map { jar in
try (jar["name"]^, jar["age"]^)
}
Or when your type does not conform to JarRepresentable
, as it might need some additional initialization data:
let account: Account = ...
let payments: [Payment] = try (jar["payments"]^ as Array).map {
Payment(jar: $0, account: account)
}
Even though you can manually transform values to add additional initialization data, it is often more convenient to add this data to the jar's context instead. Jar contexts will be described further down.
Setting and getting values of unknown types is not unique to JSON. Many Cocoa APIs use dictionaries and many of them are based on similar principles as JSON, such as p-lists. Lift provides protocols for extending those types to grant them access the power of Lift. E.g. Lift already extends UserDefaults:
// extension UserDefaults: MutatingValueForKey { }
let userDefaults = UserDefaults.standard
let date: Date? = try userDefaults["lastLaunched"]^
userDefaults["lastLaunched"] = Date()
let payments: [Payments] = try userDefaults["payments"]^ ?? []
Out of the box, the Lift library supports JSON dictionaries, arrays and its primitive types: string, number, bool and null. But it is easy to extend your own types to work with the Lift library as well.
To be able extract values out of a Jar
using the lift operator ^
, you conform your type to the JarConvertible
protocol:
protocol JarConvertible {
init(jar: Jar) throws
}
And to be able to convert your type to a Jar
, you conform your type to JarRepresentable
:
protocol JarRepresentable {
var jar: Jar { get }
}
It is common to implement both these protocols hence the convenience JarElement
type alias:
typealias JarElement = JarConvertible & JarRepresentable
The Lift library includes extensions for the most common primitive types such as Int
, Bool
, String
, etc., by conforming them to JarElement
.
Your custom types are typically either simple types such as:
struct Money {
let fractionized: Int
}
extension Money: JarElement {
init(jar: Jar) throws {
fractionized = try jar^
}
var jar: Jar { return Jar(fractionized) }
}
let jar: Jar = ["amount": Money(fractionized: 2000)]
let amount: Money = try jar["amount"]^
Or perhaps more common, more complex and record like types such as:
struct Payment {
let amount: Money
let date: Date
}
extension Payment: JarElement {
init(jar: Jar) throws {
amount = try jar["amount"]^
date = try jar["date"]^
}
var jar: Jar {
return ["amount": amount, "date": date]
}
}
let jar: Jar = ["payment": Payment(...)]
let payment: Payment = try jar["payment"]^
To make it easier to conform your custom enums with raw values, Lift comes with some default implementations. All you have to do is to conform the enum to JarElement
to be able to use it with Jar
s:
enum MyEnum: String, JarElement {
case one, two, three
}
let jar: Jar = ["enum": MyEnum.two]
let str: String = try jar["enum"]^ // -> "two"
let myEnum: MyEnum = try jar["enum"]^ // -> .two
JarConvertible
requires you to implement a required init. This can be problematic if you work with non-final classes where you cannot update the source itself, such as when the class originates from Objective-C or another external source. In those cases you have to use the Liftable
protocol instead:
extension MyClass: Liftable {
static func lift(from jar: Jar) throws -> MyClass {
// Implementation
}
}
The Lift library does not enforce the structure of you custom types and also allows retroactive modeling. It is up to you how you decide to map between your types and JSON. For example you might have enums with associative values (in this example a recursive one):
// [ { "type": "Product", "uuid": ”3b0bb980-2c…” },
// { "type": "Folder", "name": "Coffee", "items": [
// { "type": "Product", "uuid": ”3e493140-2c…” },
// { "type": "Product", ”uuid": ”3e623780-2c…” }] },
// ... ]
indirect enum FlowLayout {
case product(uuid: UUID)
case folder(name: String, items: [FlowLayout])
}
Because the JSON format has a weaker type-system than Swift, stricter validation becomes more important:
extension FlowLayout: JarConvertible {
init(jar: Jar) throws {
switch try jar["type"]^ as String {
case "Product":
self = try .product(uuid: jar["uuid"]^)
case "Folder":
self = try .folder(name: jar["name"]^, items: jar["items"]^)
case let type:
throw jar.assertionFailure("Unknown layout type: \(type)")
}
}
}
Even for these more complex types, generation of JSON is still quite straightforward:
extension FlowLayout: JarRepresentable {
var jar: Jar {
switch self {
case let .product(uuid):
return ["type": "Product", "uuid": uuid]
case let .folder(name, items):
return ["type": "Folder", "name": name, "items": items]
}
}
}
Because JSON is typically nested, it is useful to extend errors with some positioning and context. Lift tries to keep track of the closest context and "key-path" into your data and will expose those in LiftError
s:
struct LiftError: Error {
let message: String
let key: String
let context: String
}
Because the context and key-path are really valuable during debugging, it is important to not lose those when throwing validation errors. Hence, Lift has added special assert helper methods to Jar
that you are encouraged to use:
init(jar: Jar) throws {
// ...
try jar.assert(i > 0, "Must greater than zero")
guard validate(...) else {
throw jar.assertionFailure("Not a business nor a person")
}
url = try jar.assertNotNil(URL(string: jar^), "Invalid URL")
// ...
}
Sometimes your type's initializer needs access to more data than what is included in the JSON itself. E.g. perhaps your Money
type needs a currency as well, but your JSON does not provide that or provides it far away from the actual amount value itself. This is where you can pass the currency in the jar's context instead:
struct Money {
let fractionized: Int
let currency: Currency
}
extension Money: JarElement {
init(jar: Jar) throws {
fractionized = try jar^
currency = try jar.context.get() // will extract the currency
}
var jar: Jar { return Jar(fractionized) }
}
let amount: Money = try jar.union(context: currency)["amount"]^
The jar's context is also useful for customizing the encoding and decoding of your types. E.g. Date
will by default use the ISO8601 date format, but by providing another DateFormatter
in the jar's context you could customize the date format:
extension Date: JarConvertible, JarRepresentableWithContext {
init(jar: Jar) throws {
let formatter: DateFormatter = jar.context.get() ?? .iso8601
self = try jar.assertNotNil(formatter.date(from: jar^), "Date failed to convert using formatter with dateFormat: \(formatter.dateFormat)")
}
func asJar(using context: Jar.Context) -> Jar {
let formatter: DateFormatter = context.get() ?? .iso8601
return Jar(formatter.string(from: self))
}
}
As JarRepresentable
does not provide any context, you will instead conform to JarRepresentableWithContext
that passes the context in asJar
:
protocol JarRepresentableWithContext: JarRepresentable {
func asJar(using context: Jar.Context) -> Jar
}
The context could either be set externally or as part of some other type's encoding/decoding such as:
struct Payment {
let amount: Money
let date: Date
}
extension Payment: JarElement {
init(jar: Jar) throws {
let jar = jar.union(context: DateFormatter.custom)
amount = try jar["amount"]^ // a currency must be provided in the jar's context
date = try jar["date"]^ // date will format using DateFormatter.custom
}
var jar: Jar {
let jar: Jar = ["amount": amount, "date": date]
return jar.union(context: DateFormatter.custom)
}
}
let payment: Payment = try jar.union(context: currency)["payment"]^
Lift was developed, evolved and field-tested over the course of several years, and is pervasively used in iZettle's highly acclaimed point of sales app for communicating with iZettle's comprehensive set of backend services.
You can collaborate with us on our Slack workspace. Ask questions, share ideas or maybe just participate in ongoing discussions. To get an invitation, write to us at [email protected]
To learn more about how Lift's APIs turned the way they did, we recommend reading the article: