Video #177: Parser Errors: Context and Ergonomics
Episode: Video #177 Date: Feb 7, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep177-parser-errors-context-and-ergonomics

Description
Let’s make errors a pleasure to encounter! We will make them easy to read, add more context to make them easy to debug, and even see how error messages can influence existing APIs.
Video
Cloudflare Stream video ID: 829254a6b748f5746371187be4ef56c0 Local file: video_177_parser-errors-context-and-ergonomics.mp4 *(download with --video 177)*
Transcript
— 0:05
This is already pretty nice. We are running 5 parsers on the input string, and the error message has made it much easier for us to track down that something is wrong with the boolean in the string.
— 0:15
Now luckily the input string is quite short, and there’s only one boolean we are trying to parse, so it’s simple to figure out exactly what went wrong. However, if the input was hundreds or thousands of characters long and there were multiple places we were parsing booleans then it wouldn’t be super helpful to know that somewhere a boolean failed to parse. We’d also like to know where exactly the parse failure happened.
— 0:39
Let’s see what it takes to do that. Contextual errors
— 0:43
A naive approach would be to include this extra, contextual information right in the ParsingError message when we throw the error: throw ParsingError("expected boolean, found, \(input)")
— 1:02
This doesn’t work because the boolean parser is very generic. In fact it works on any collection of UTF-8 code units, which is done for performance.
— 1:19
And so we need to do a little extra work to convert this collection into a string: throw ParsingError( """ expected boolean, found \ \(String(decoding: input, as: UTF8.self)) """ )
— 1:38
Now the error message looks a little more interesting: caught error: expected boolean, found true
— 1:55
Now we can see exactly where it tripped up to parse the boolean. And if there was a whole bunch of other stuff to parse after that we would also have that context: var input = """ 1,Blob,tru 2,Blob Jr,false 3,Blob Sr,true """[...] _ = try user.parse(&input) as User caught error: expected boolean, found tru 2,Blob Jr,false 3,Blob Sr,true
— 2:29
Let’s try out some other errors. Like say we corrupted the beginning of the input to not be an integer: var input = """ one,Blob,tru 2,Blob Jr,false 3,Blob Sr,true """[...] _ = try user.parse(&input) as User caught error: expected integer
— 2:37
We’re back to a slightly less helpful error message than what we had before. We know that somewhere in the parser’s lifetime we tried parsing an integer and that it failed, but the message doesn’t tell us exactly where it happened.
— 2:50
We now know how to improve these error messages, so let’s hop over to the IntParser and update all of our throw call sites to include the rest of the string that is left to parse: throw ParsingError( """ expected integer, found \ \(String(decoding: input, as: UTF8.self)) """ ) … throw ParsingError( """ expected integer, found \ \(String(decoding: input, as: UTF8.self)) """ ) … throw ParsingError( """ expected integer, found \ \(String(decoding: input, as: UTF8.self)) """ ) … throw ParsingError( """ expected integer not to overflow, found \ \(String(decoding: input, as: UTF8.self)) """ ) … throw ParsingError( """ expected integer not to overflow, found \ \(String(decoding: input, as: UTF8.self)) """ ) … throw ParsingError( """ expected integer, found \ \(String(decoding: input, as: UTF8.self)) """ )
— 3:31
It’s a little gross that we are copying and pasting this code everywhere, but at least we now get an error message that shows exactly where the error occurred: caught error: expected integer, found one,Blob Jr,true 2,Blob Jr,false 3,Blob Sr,true
— 3:45
We can also run that test that expects the integer to overflow. The error message now includes context of the integer it tried to parse. caught error: expected integer not to overflow, found 256
— 4:00
Let’s look at one more failure. Let’s see what happens when our input has a “-” instead of a “,” between two fields: var input = """ 1-Blob,true 2,Blob Jr,false 3,Blob Sr,true """[...] _ = try user.parse(&input) as User caught error: expected ","
— 4:15
Looks like we have yet another parser that is not printing out more context information of where the error actually occurred, so let’s fix it: @inlinable public func parse(_ input: inout Substring) throws { guard input.starts(with: self) else { throw ParsingError("expected \(self.debugDescription), found \(input)") } input.removeFirst(self.count) }
— 4:40
And now our error message has more context: caught error: expected "," , found -Blob,true 2,Blob Jr,false 3,Blob Sr,true Better contextual errors
— 4:51
So, we’ve done a pretty good job of contextualizing error messages so far. We could keep updating more and more parsers to throw contextualized errors instead of returning nil , but as we are starting to see, this is becoming untenable. We don’t wanna just keep manually interpolating the rest of the input string into the ParsingError type. It would be nice to consolidate and automate this work, and in doing so we can have a single place to improve error messaging for all parsers to benefit from.
— 5:20
Rather than having ParsingError take the full error message we want to show the user, let’s have it hold onto some more basic information and then have it be responsible for turning that into a nicely formatted string. All of our error messages so far have been of the form “X expected, found Y”. Sounds like the things we most care about right now is what the parser expected to extract from the input and what the parser actually encountered: @usableFromInline struct ParsingError: Error { let expected: String let remainingInput: String … }
— 5:59
And we don’t want to have to fix all of our existing usages of ParsingError right now, so we will continue to allow the init that just takes a message, but we will deprecate it: @usableFromInline @available(*, deprecated) init(_ message: String = "🛑 NO ERROR MESSAGE PROVIDED") { self.expected = message self.remainingInput = "" }
— 6:23
And we’ll provide an initializer for when we can specify both the expected and remaining input: @usableFromInline init(expected: String, remainingInput: String) { self.expected = expected self.remainingInput = remainingInput }
— 6:35
And the only compiler error we have now is our conformance to CustomDebugStringConvertible for better message formatting in tests. Now that we have expected and remaining input as separate fields we can better format this information: extension ParsingError: CustomDebugStringConvertible { @usableFromInline var debugDescription: String { """ Parsing error: Expected: \(self.expected) Found: \(self.remainingInput) """ } }
— 7:03
Of course, none of our current test failures will look particularly nice yet because none of our thrown errors are using this new initializer on ParsingError . Let’s update a few of the deprecated initializers to the new style to see how it is using this new error.
— 7:13
For example, we can update the boolean parser with a better error message: throw ParsingError(expected: "a boolean", remainingInput: String(decoding: input, as: UTF8.self))
— 7:31
And now if we misspell “true” in the input: var input = """ 1,Blob,tru 2,Blob Jr,false 3,Blob Sr,true """[...] _ = try user.parse(&input) as User
— 7:40
We get an error that makes it very easy to see exactly where the problem was: caught error: Parsing error: Expected: a boolean Found: tru 2,Blob Jr,false 3,Blob Sr,true"
— 7:45
This is looking really nice.
— 7:55
Let’s also update the IntParser . We can update all 6 thrown errors to the new style: throw ParsingError(expected: "an integer", remainingInput: String(decoding: input, as: UTF8.self)) … throw ParsingError(expected: "an integer", remainingInput: String(decoding: input, as: UTF8.self)) … throw ParsingError(expected: "an integer", remainingInput: String(decoding: input, as: UTF8.self)) … throw ParsingError(expected: "an integer to not overflow", remainingInput: String(decoding: input, as: UTF8.self)) … throw ParsingError(expected: "an integer to not overflow", remainingInput: String(decoding: input, as: UTF8.self)) … throw ParsingError(expected: "an integer", remainingInput: String(decoding: input, as: UTF8.self))
— 8:35
Now if we go back to our user parser tests and insert some bad data for the id: var input = """ one,Blob,true 2,Blob Jr,false 3,Blob Sr,true """[...] _ = try user.parse(&input) as User caught error: Parsing error: Expected: an integer Found: one,Blob,true 2,Blob Jr,false 3,Blob Sr,true
— 8:43
We get a really great error.
— 8:47
And similarly for the overflowing integer. caught error: Parsing error: Expected: an integer to not overflow Found: 256
— 8:56
One nice thing about this is that we no longer have to repeat the template of “Expect a/an X, found a Y”. We can just concentrate on the core details of the error, which is that we expected an integer. It does seem a little repetitive to have to decode a string from the collection of UTF-8 code units each time.
— 9:19
It would be nice if we could hide this work in the ParsingError somehow. Perhaps we can allow the ParsingError take any kind of remaining input, not just strings, and then it could could the hard work of figuring out how to format the message under the hood.
— 9:26
Now one approach to this would be to make ParsingError generic: @usableFromInline struct ParsingError<Input>: Error { let expected: String let remainingInput: Input … }
— 9:39
But we don’t think we need to preserve this type information. We can start more simply by using a fully erased Any type, and then we can introduce a generic later if we think we need all that power: @usableFromInline struct ParsingError: Error { let expected: String let remainingInput: Any @usableFromInline init(expected: String, remainingInput: Any) { self.expected = expected self.remainingInput = remainingInput } … }
— 9:49
And everything technically compiles now, but that’s because we weren’t really making use of the fact that remainingInput is a string. We were just interpolating it into a string.
— 10:00
That isn’t going to be the right thing to do once we start passing along raw, non-converted inputs to the ParsingError type. We need to do a little bit of upfront work to figure out what the input looks like so that we can convert it into something reasonable. We can do this by switching on its type: var debugDescription: String { let rest: String switch self.remainingInput { case let input as Substring: rest = String(input) case let input as Substring.UTF8View: rest = String(decoding: input, as: UTF8.self) default: rest = "\(self.remainingInput)" } return """ Parsing error: Expected: \(self.expected) Found: \(rest) """ }
— 11:00
Now we can throw parsing errors in a simpler way without sacrificing the quality of the error message: throw ParsingError(expected: "a boolean", remainingInput: input) … throw ParsingError(expected: "an integer", remainingInput: input) … throw ParsingError(expected: "an integer", remainingInput: input) … throw ParsingError(expected: "an integer", remainingInput: input) … throw ParsingError(expected: "an integer to not overflow", remainingInput: input) … throw ParsingError(expected: "an integer to not overflow", remainingInput: input) … throw ParsingError(expected: "an integer", remainingInput: input)
— 11:58
In fact, throwing errors is so easy now, let’s update a few more, such as our string prefix parser: public func parse(_ input: inout Substring) throws { guard input.starts(with: self) else { throw ParsingError(expected: self.debugDescription, remainingInput: input) } input.removeFirst(self.count) }
— 12:21
And the End parser: public func parse(_ input: inout Input) throws { guard input.isEmpty else { throw ParsingError(expected: "end of input", remainingInput: input) } }
— 12:31
It’s worth noting that we haven’t had to update any of the parser combinators yet because they don’t typically throw new errors. Most of them just throw the errors of the parsers held internally.
— 12:43
Let’s give these new parsers a spin. Say that after the user parser runs we want to verify that there was nothing left to parse: let user = Parse(User.init) { Int.parser() "," Prefix { $0 != "," }.map(String.init) "," Bool.parser() End() }
— 13:05
This will of course fail because we have another row of data left to parse, but just to see what this failure looks like let’s run tests: caught error: Parsing error: Expected: Error: NO ERROR MESSAGE PROVIDED Found:
— 13:10
Not a very good message. This is because now that we are using 6 parsers in the builder closure we have implicitly moved to use a different zip parser. One that has not yet been updated to the throwing style. The parser in question is the ZipOVOVOV parser, which we can update easily enough: @inlinable public func parse(_ input: inout P0.Input) throws -> ( P0.Output, P2.Output, P4.Output ) { let original = input do { let o0 = try p0.parse(&input) as P0.Output let _ = try p1.parse(&input) as P1.Output let o2 = try p2.parse(&input) as P2.Output let _ = try p3.parse(&input) as P3.Output let o4 = try p4.parse(&input) as P4.Output let _ = try p5.parse(&input) as P5.Output return (o0, o2, o4) } catch { input = original throw error } }
— 14:05
And now our parser failure message looks great: caught error: Parsing error: Expected: end of input Found: 2,Blob Jr,false 3,Blob Sr,true
— 14:20
There’s something pretty simple we can do to improve this error message even further. If the inputs you are dealing with are quite large you may want some more precise information about where exactly the error occurred, such as the line and column number.
— 14:36
To do this we somehow need access to the original input string when parsing was first kicked off. Amazingly it turns out that Substring exposes this information as a base property: var substring = "Hello World".dropLast(6) // "Hello" substring.base // "Hello World"
— 15:26
This is exactly what we need to further contextualize the error to describe where it happened within the greater input string. We can simply scan the base string from the beginning up until where the parse error happened, and along the way we will keep track of the lines and columns: case let input as Substring: let (line, col) = input.base[..<input.startIndex] .reduce(into: (line: 1, col: 1)) { (context: inout (line: Int, col: Int), char: Character) in if char == "\n" { context.line += 1 context.col = 1 } else { context.col += 1 } } rest = String(input) Right now let’s just do this work for Substring , but it can also be done for UTF8View .
— 17:11
Once the line and column are computed we can compute an additional string for the error title: let errorTitle: String switch self.remainingInput { case let input as Substring: … errorTitle = " @ (\(line):\(col))" case let input as Substring.UTF8View: rest = String(decoding: input, as: UTF8.self) errorTitle = "" default: rest = "\(self.remainingInput)" errorTitle = "" } And then we can interpolate that into the final error message: return """ Parsing error\(errorTitle): Expected: \(self.expected) Found: \(rest) """
— 17:56
And now our error message exposes the line and column of where the error was found: caught error: Parsing error @ (1:12): Expected: end of input Found: 2,Blob Jr,false 3,Blob Sr,true OneOf errors
— 18:16
We are now getting better and better errors, and we are converting more and more of our parsers to use the new throwing requirement instead of the optional-returning requirement, which gives each parser the opportunity to describe what went wrong.
— 18:29
There is very powerful parser that we’ve used a bunch in the past but haven’t yet upgraded to deal with errors, and it comes with some interesting new subtleties and complications. And that’s the OneOf parser. Recall that its purpose is to run a bunch of parsers on the same input and take the first one to succeed. This is great for trying to parse an input into an enum since it allows you to try a bunch of different parsers, one for each case of the enum.
— 19:00
In past episodes we made use of this parser in order to parse an enhanced user struct, where we use an enum to model a choice of role rather than a simple boolean: enum Role { case guest, member, admin } struct User { var id: Int var name: String var role: Role }
— 19:21
To parse a Role value we can use the OneOf parser to list out a parser for each case: let role = OneOf { "guest".map { Role.guest } "member".map { Role.member } "admin".map { Role.admin } }
— 19:47
And then use that in the user parser instead of a boolean parser: let user = Parse(User.init) { Int.parser() "," Prefix { $0 != "," }.map(String.init) "," role End() }
— 19:52
Now when we run the parser on the version of the input string that uses booleans instead of roles we get a failure: var input = """ 1,Blob,true 2,Blob Jr,false 3,Blob Sr,true """[...] _ = try user.parse(&input) as User caught error: Parsing error: Expected: Error: NO ERROR MESSAGE PROVIDED Found:
— 20:01
But it’s not very good because we started using a parser that still uses the old optional-returning style of parsing rather than the throwing style. In fact, there are two.
— 20:09
Most obviously we have the OneOf parser, which acts as an entry point into builder syntax so that we can list many parsers inside. We can convert it to the throwing style by just calling try on the underlying parser: public struct OneOf<Parsers>: Parser where Parsers: Parser { … @inlinable public func parse(_ input: inout Parsers.Input) throws -> Parsers.Output { try self.parsers.parse(&input) } }
— 20:29
But that still isn’t enough to get a better error message because under the hood the builder syntax is building up a different type. Since we have 3 parsers instead in this OneOf block it is secretly constructing a OneOf3 parser, which is another type that is code generated for us by the library: extension Parsers { public struct OneOf3<P0, P1, P2>: Parser where P0: Parser, P1: Parser, P2: Parser, P0.Input == P1.Input, P1.Input == P2.Input, P0.Output == P1.Output, P1.Output == P2.Output { public typealias Input = P0.Input public typealias Output = P0.Output public let p0: P0, p1: P1, p2: P2 @inlinable public init(_ p0: P0, _ p1: P1, _ p2: P2) { self.p0 = p0 self.p1 = p1 self.p2 = p2 } @inlinable public func parse(_ input: inout P0.Input) -> P0.Output? { if let output = self.p0.parse(&input) { return output } if let output = self.p1.parse(&input) { return output } if let output = self.p2.parse(&input) { return output } return nil } } }
— 20:52
From its implementation we can clearly see that it just tries one parser after another until it finds one that succeeds, and then short circuits the rest of the parsers by returning early.
— 21:00
We want to capture the essence of this logic, but using throws instead of returning optionals. We can start by trying the first parser, and if that fails we try the second, and if that fails we finally try the third: @inlinable public func parse(_ input: inout P0.Input) throws -> P0.Output { do { return try self.p0.parse(&input) } catch { do { return try self.p1.parse(&input) } catch { return try self.p2.parse(&input) } } }
— 21:50
This gets things compiling, and now when we run tests we get a failure because still our input string holds a boolean instead of a valid role: caught error: Parsing error @ (1:8): Expected: "admin" Found: true 2,Blob Jr,false 3,Blob Sr,true
— 21:59
This is a pretty good error message. It says that we expected “admin”, and the reason it does that is because the admin role parser is using the string parser to check if the input begins with the literal “admin”.
— 22:16
However, more went wrong than just the input not beginning with “admin.” In order to get that error it must have meant that also the input did not begin with “guest” or “member”. As it is implement now, OneOf will only throw the error of the last parser in the OneOf block. We won’t know what went wrong with any of the other parsers.
— 22:36
It would be nice for OneOf ’s error message to include each of the errors thrown by the parsers run. But how can we do that? Well, first of all we are going to need to collect all of the errors as they are thrown, so let’s get them explicit names in the catch : @inlinable public func parse(_ input: inout P0.Input) throws -> P0.Output { do { return try self.p0.parse(&input) } catch let e0 { do { return try self.p1.parse(&input) } catch let e1 { do { return try self.p2.parse(&input) } catch let e2 { throw ??? } } } }
— 23:21
And in here we need to throw a new error that someone combines all of the other errors encountered. This is in fact the first time we have come across a parser combinator, that is a parser that uses other parsers to do its job, that throws a new type of error and does not simply rethrow an error from one of the internal parsers.
— 23:26
The new error we throw will collect all the expectations of the other throw errors and put them into a nicely formatted, bullet point list: @inlinable public func parse(_ input: inout P0.Input) throws -> P0.Output { do { return try self.p0.parse(&input) } catch let e0 as ParsingError { do { return try self.p1.parse(&input) } catch let e1 as ParsingError { do { return try self.p2.parse(&input) } catch let e2 as ParsingError { throw ParsingError( expected: [e0, e1, e2].map { "- \($0.expected)" }.joined(separator: "\n"), remainingInput: input ) } } } }
— 24:31
However, in order for us to access the internal expected property of an error from an inlineable function, like this parse method, we need to mark the property as @usableFromInline : @usableFromInline struct ParsingError: Error { @usableFromInline let expected: String … }
— 24:42
And now when we run tests we get an amazingly helpful error message: caught error: Parsing error @ (1:8): Expected: - "guest" - "member" - "admin" Found: true 2,Blob Jr,false 3,Blob Sr,true
— 24:52
We can clearly see every single parser that was run on the input and why it failed.
— 25:02
Many
— 25:02
The OneOf parser has perhaps the most complicated error messaging of anything we have so far covered. It needs to collect all of the errors from each parser, and then repackage them into something sensible. We could probably even do a little more work to make the error message a little nicer, but it’s not so bad already.
— 25:22
So error messages just keep getting better. But there’s more. Having a proper error model for parsers, we are now free to discover whether they can help us make our existing parsers even stronger. We have a few parsers that may be a little too lenient for their own good.
— 25:44
Let’s take a look at the Many parser, which is a parser that allows you to run another parser repeatedly on an input and accumulate those outputs into some result that is usually an array. To explore that let’s copy and paste the user test we have been playing with in order to make a new test that explores the Many parser: func testUsersParser() throws { … }
— 26:04
And let’s construct a users parser and run in on some valid data: let users = Many { user } separator: { "\n" } var input = """ 1,Blob,true 2,Blob Jr,false 3,Blob Sr,true """[...] _ = try users.parse(&input) as [User]
— 26:50
This passes, which may seem strange, because we are still using booleans in the input, but have upgraded the parser to parse roles.
— 26:57
To gain some insight into what’s going on, let’s get an assertion in place that asserts that we parsed 3 users and consumed all input. let usersArray = try users.parse(&input) as [User] XCTAssertEqual(usersArray.count, 3) XCTAssertEqual(input, "") XCTAssertEqual failed: ( "0" ) is not equal to ( "3" ) XCTAssertEqual failed ( " 1,Blob,true 2,Blob Jr,false 3,Blob Sr,true " ) is not equal to ( "" )
— 27:19
It failed. In fact it didn’t extract any users out of the input, nor did it consume any of the input.
— 27:27
To see why this is happening, let’s jump over to the Many parser and search for where it might fail to parse. We haven’t yet converted it to the throwing style of parsing, but there is only one single place it decides to fail: guard count >= self.minimum else { input = original return nil }
— 27:35
This happens if once we are done running the element parser as many times as possible we find that we did not extract enough elements. So the only way for Many to fail is if we don’t meet the minimum threshold.
— 27:47
That seems a little lenient. It is very common for us to want the Many parser to consume the entirety of the input string. Let’s copy and paste the user test we have been playing with in order to make a new test that explores the Many parser:
— 27:57
If we fix the first and last rows but leave an invalid second row, with a boolean instead of a role: var input = """ 1,Blob,member 2,Blob Jr,false 3,Blob Sr,admin """[...] _ = try users.parse(&input) as [User]
— 28:05
Parsing succeeds, but our expectations do not: XCTAssertEqual failed: ( "1" ) is not equal to ( "3" ) Failed: XCTAssertEqual failed ( " 2,Blob Jr,false 3,Blob Sr,admin " ) is not equal to ( "" ) Parsing still succeeds, but it only extracts a single user from the string and then leaves some input remaining to parse.
— 28:26
So, this is a little confusing. The parser succeeded, which may lead us to believe everything is good, but then we have input left unparsed, which leads us to believe everything is not good. We’d have to decide what we want to do to handle this situation.
— 28:55
One thing we could do to force consuming the entire input is run the End() parser after the Many parser, which will cause the whole parser to fail if there is any input left: let users = Parse { Many { user } separator: { "\n" } End() } var input = """ 1,Blob,member 2,Blob Jr,gues """[...] _ = try users.parse(&input) as [User]
— 29:09
Running this we get a test failing, which is nice, but also it has a bad error message: caught error: Parsing error: Expected: Error: NO ERROR MESSAGE PROVIDED Found:
— 29:20
We are now using a parser that hasn’t been updated to the new throwing style. This time it is not the End parser, we already updated it. It’s another one of those implicit zip parsers that the parser builder is using under the hood. This time it’s ZipOV because we want to keep the output of the first parser and discard the void value of the second.
— 29:34
We can update this parser by trying each parser, and if neither fail we will return the output of the first, and if one fails we will rethrow that error: @inlinable public func parse(_ input: inout P0.Input) throws -> ( P0.Output ) { let original = input do { let o0 = try p0.parse(&input) as P0.Output let _ = try p1.parse(&input) as P1.Output return (o0) } catch { input = original throw error } }
— 29:53
Now when we run tests we get a better error message: caught error: Parsing error @ (1:14): Expected: end of input Found: 2,Blob Jr,false 3,Blob Sr,admin
— 30:02
Better, but not great.
— 30:12
We are told that we expected to not have any more input left to consume, but instead we found a newline. However, we don’t know why that input was not capable of being consumed. The element parser must have failed for some reason, but that error was thrown away by the Many parser, and so there is no better messaging we can print here.
— 30:35
This seems like a problem in Many ’s implementation. We should have the ability to force Many to fail when it didn’t finish its job, and further we should get the last failure message thrown inside the Many so that we can print a better error message.
— 30:50
The Many parser is already quite complicated. In fact, it is probably one of our most complicated parsers. And so layering on even more functionality isn’t going to be a walk in the park, but let’s give it a shot. First, how do we want to be able to customize Many in order to allow it to fail? We could possibly add a boolean parameter, like say isExhaustive or doesConsumeAll or whatever, but unfortunately Many is too generic for that. We actually don’t know anything about Many ’s input. We don’t know if it’s string-based or even collection-based. This means there may not be a sensible way to check if the input has been fully consumed.
— 31:50
Further, we may want more nuanced logic to determine if the Many parser did its job correctly. Maybe checking that we are at the end of the input isn’t quite right and instead we want to see if there are some specific characters at the end.
— 32:08
This leads us to believe that perhaps the Many parser should be configurable with another parser, similar to the separator, but it is run once the Many has consumed as much as it can in order to verify that it has done its job correctly.
— 32:28
We can even take inspiration from a Swift standard library function to figure out the name of this new parser. The print function takes a variadic list of things to print, a separator to be printed between each item, and a terminator that is printed at the very end: print(<#Any#>, separator: <#String#>, terminator: <#String#>)
— 32:49
Now, you may think it’s weird that we are getting inspiration for a parser from a print function. However, we are soon going to explore on Point-Free how parsing and printing are just two sides of the same coin and are intimately related. It is going to be amazing to see, but until then you will just have to believe us.
— 33:08
So, let’s see what it will take to update the Many parser to make use of a “terminator” parser in order to figure out if it did its job. Let’s start by converting the Many parser to be throwing because it is still using the old optional-returning style: public func parse(_ input: inout Element.Input) throws -> Result { … }
— 33:29
And then there is only one spot where we return nil , and that’s when the minimum number of elements are not parsed: guard count >= self.minimum else { defer { input = original } throw ParsingError( expected: "\(self.minimum) values of \(Element.Output.self)", remainingInput: input ) }
— 34:16
That will give us some basic error messaging for when this condition fails, but this isn’t what we are really interested in.
— 34:22
We want the Many parser to hold onto yet another parser under the hood which will be run once the element parser has consumed as much as it can. In order to preserve as much type information as possible, and to not have to erase to AnyParser , let’s introduce yet another generic to the Many parser for this new terminator: public struct Many<Element, Result, Separator, Terminator>: Parser where …
— 34:44
And this new generic must be a parser, and must work on the same kind of input as the element and separator parsers, but otherwise can be anything: public struct Many<Element, Result, Separator, Terminator>: Parser where Element: Parser, Separator: Parser, Separator.Input == Element.Input, Terminator: Parser, Terminator.Input == Element.Input { … }
— 34:55
We’ll hold onto a terminator parser in the Many type: public let terminator: Terminator
— 35:05
This breaks all of our initializers, but for the time being let’s quickly add the terminator to each initializer.
— 35:50
We also have some broken deprecated code, but let’s not worry about any of that for now.
— 36:12
Now the parser library is building, but tests are not because we have a bunch of usages of Many that do not specify a terminator. We can definitely add more convenience initializers to allow for omitting the terminator, but that’s not really important to look into now, so we will comment out those tests.
— 36:38
Now there’s only one compiler error, and it’s in our construction of the users parser, where we do want to specify a terminator parser in order to verify that we consumed all of the input: let users = Many { user } separator: { "\n" } terminator: { End() }
— 37:07
Now everything compiles, but this test will pass, and that’s because we aren’t actually using the terminator yet.
— 37:28
If we look at the parse method of Many we will see a quite complex chunk of logic. One thing we can do to simplify is temporarily delete all of the #if DEBUG code, which is code that only runs in development in order to check for potential infinite loops. This can happen if you use an element parser that succeeds but doesn’t consume any input. We will get stuck in an infinite loop that can never break.
— 38:10
The easiest thing to do would be to simply run the terminator parser just before returning the result so that we can be sure it terminated correctly: do { _ = try self.terminator.parse(&input) as Terminator.Output } catch { input = original throw error } return result
— 38:57
Now if we run tests we get a failure, which is good, but it’s back to being a not super helpful message: caught error: Parsing error @ (1:14): Expected: end of input Found: 2,Blob Jr,false 3,Blob Sr,admin
— 39:20
This is happening because we are just throwing the terminator’s error rather than the last error we encountered when running the element or separator parser. If we captured one of those errors we would get a much better error message.
— 39:44
Currently we don’t have access to the element parser’s error because we aren’t even using the throwing version of the parse method: while count < self.maximum, let output = self.element.parse(&input) { … }
— 39:56
This just continuous looping until the element parser fails, or until the maximum is reached. We can refactor this to invoke the element parser in the loop, and if it throws an error we will just break out of the loop: while count < self.maximum { let output: Element.Output do { output = try self.element.parse(&input) } catch { break } … }
— 40:34
This should work exactly as it did before, but now we are in a position to capture the element error: var loopError: Error? while count < self.maximum { let output: Element.Output do { output = try self.element.parse(&input) } catch { loopError = error break } … }
— 40:49
And this is the error we can throw when the terminator fails if it exists, and if not we can fallback on the terminator’s error: do { _ = try self.terminator.parse(&input) as Terminator.Output } catch { input = original throw loopError ?? error }
— 41:02
Now when we run tests we get something much nicer. caught error: Parsing error @ (2:11): Expected: - "guest" - "member" - "admin" Found: false 3,Blob Sr,admin
— 41:10
We are told that we failed because we tried parsing “guest”, “member”, or “admin” from the string, but instead we only found “false”, followed by another row.
— 41:23
Further, what if the error happened at the separator rather than the element? So, instead of having an invalid role, what if we add a trailing slash? var input = """ 1,Blob,member 2,Blob Jr,guest- 3,Blob Sr,admin """[...]
— 41:40
Running tests we get an error, but it’s not very helpful: caught error: Parsing error @ (2:16): Expected: end of input Found: - 2,Blob Jr,guest 3,Blob Sr,admin
— 41:47
This just tells us that the Many parser did not consume the whole input, but we don’t know what went wrong.
— 41:55
To fix this we need to incorporate the separator parser’s error too. This is as simple as capturing the error of the separator when thrown, and assigning it to the loopError since that it’s the one we print the details of later: do { _ = try self.separator.parse(&input) as Separator.Output } catch { loopError = error break }
— 42:30
Now when we run tests we get a failure: caught error: Parsing error @ (1:14): Expected: "\n" Found: - 3,Blob Sr,admin
— 42:34
But now it tells us exactly what went wrong. After parsing the first row we expected to them parse a newline to get to the second row, but instead we found a dash “-”.
— 42:46
And of course if we go back to a valid input we get a passing test: var input = """ 1,Blob,member 2,Blob Jr,guest 3,Blob Sr,admin """[...]
— 42:52
This is pretty incredible. We are getting super descriptive error messages that can zero in on the exact point the parser fails. Conclusion
— 43:01
And honestly this is only the beginning. We wanted to just give you a taste of what it takes to bring error messaging to our parsers, and show how errors can even improve the expressiveness of our parsers. This week we are releasing a new version of our swift-parsing library with errors, and the error messaging is even better than what we have covered so far in these episodes.
— 43:25
Next week we finally start a series of episodes that we actually planned to start last week, but at the last minute we realized we really needed to cover error messaging before we could do it justice. It’s a topic we have been working on and refining for over 4 years. Before Point-Free even launched.
— 43:43
Until next time! Downloads Sample code 0177-parser-errors-pt2 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .