I’d like to go back to basics, trying to make my case.
Here’s a typical use case. Recently I work with these types of schemas in TypeScript all day. (I don’t write them, but I use and maintain code that depends on them).
Here’s a very simple example. I don’t present this to be criticized – please accept that this is a common style of describing things in our project, and I believe this style is common throughout the TypeScript (and JavaScript) world.
export type Event = {
day: string;
timeRange?: EventTimeRange;
translatedDate?: string;
description: string;
location?: string;
participants?: string[];
};
export type EventTimeRange = {
startTime?: string;
endTime?: string;
duration?: string;
};
(The notation name?: type
in TS means “field may be present, with this type, or field may be missing”. Attempts to access such optional fields if the field is missing return undefined
, which is close to Python’s None
in meaning, though undefined
is also returned for cases where Python would raise e.g. AttributeError or KeyError.)
This data structure is common and it’s designed to be transmitted to JSON. The transformation to JSON omits fields that are missing from the in-memory data structure, for compactness (JSON is verbose, which makes it expensive to transmit; there’s a general culture to keep it short were possible).
So e.g. a minimal Event instance could be
{
"day": "tomorrow",
"description": "lunch"
}
Here, there is no location given, and the system can’t “fix” that by making one up. Ditto for the duration or time range, and the participants. (This might represent a calendar Event that the user intends to update at a later time.)
If I received that JSON blob in Python, I’d have a nested dict looking exactly like that. Today, if I wanted to look at the event’s start time (defaulting to None if not given), I’d have to write something like
start_time = None
if "timeRange" in event and "startTime" in event["timeRange"]:
start_time = event["timeRange"]["startTime"]
Using the ?
notation to mean what I’d like it to mean, I could write this as
start_time = event["timeRange"]?["startTime"]?
Here, THING[KEY]?
must have two effects:
- First, if THING has no key KEY, the expression evaluates to None.
- Second, if the expression is None, subsequent
[KEY]
, .ATTR
, and (ARGUMENTS)
operations are not evaluated and the result of the entire “primary” is None.
A “primary” is a Python grammatical concept, it always starts with an “atom” (a name, certain reserved keywords, or something parenthesized or bracketed) and is followed by zero or more “suffixes” (this I made up), and a suffix is either an attribute-taking (P.NAME
), a subscription (P[KEY]
or P[SLICE...]
), or a call (P(ARGUMENTS)
). Some grammar changes are needed to describe it as a chain rather than recursively. Possibly P[SLICE...]
does not catch exceptions but only checks for None.
Detail that may be skipped on first reading: In e.g. (foo.bar).baz
, the first primary is foo.bar
and the power of ?
if present would be limited to that – (foo?.bar).baz
skips the .bar
lookup if foo
is None, but it would evaluate None.baz
. However, in foo?.bar.baz
, both the .bar
and the .baz
lookup are skipped if foo is None. This would require some tweaks to the grammar, but the scope of ?
should be carefully limited.
There would also be a THING.NAME?
operation that would work similarly for Python objects with optional fields (possibly created from JSON using e.g. https://github.com/nandoabreu/python-dict2dot
, but possibly just due to some other legitimate design choice).
For calls, THING(ARGUMENTS)?
would not catch exceptions from the call, but it would skip the rest of that primary, returning None right there.
PS. I’d prefer not to describe THING[KEY]?
and THING.NAME?
as catching exceptions – I would describe them as “if the key/attribute does not exist in THING the result is None rather than an exception.” This can be described formally as KEY in THING
, and for attributes we can use getattr(THING, "NAME", None)
. We might eventually introduce new dunders so some types can implement the combined operation faster, but that’s not necessary in the first round. If those dunders existed, they could still raise KeyError, IndexError or AttributeError (from internal bugs), and those would not be caught. When not implemented, a default implementation that does catch exceptions might used as a fallback (like hasattr()).