Mapping out the environment
The only logical step from Step 2: Eval has got to be Step 3: Environments. Fortunately this step is another small one wherein variables can be defined and bindings introduced. This means it will also make for a small post, speaking of which:
Once more with generics
The implementation here is pretty straightforward: we introduce an
Env class which acts like a linked lookup table like so:
export default class Env {
outer: Env | null;
data: { [index: string]: MalType };
constructor(outer?: Env) {
this.outer = outer ?? null;
this.data = {}
}
set(k: MalSymbol, v: MalType): MalType {
this.data[k.value] = v
return v
}
find(k: MalSymbol): Env | null {
if(k.value in this.data)
return this
else
if(this.outer)
return this.outer.find(k)
else
return null
}
get(k: MalSymbol): MalType {
const env = this.find(k)
if(env !== null)
return env.data[k.value]!
else
// Slightly awkward error phrasing to satisfy tests.
throw `The symbol '${k.value}' not found in the environment`
}
}
The only fun new (to me) TypeScript feature there is ??, aka the
nullish coalescing operator1, which is a
superb addition to the language (alongside optional chaining which
CoffeeScript added over 14 years ago at the time of writing2).
Curious to see what the compiled JavaScript would look like I ran
deno bundle and saw an unadorned ??! So here I am shocked to
discover that this operator, and optional chaining, are
now in JavaScript! What else have I missed …
At any rate the other thing I learnt in this step was the
typeof type operator, of which I was aware but hadn't had an
opportunity to use in anger. Where I needed it was in making the
generation of lists and vectors generic in eval_ast. This can be
seen more apparently in the original code:
case 'list': {
return ast.values.reduce(
(a: MalList, b: MalType) => mal.list(a.values.concat([EVAL(b, env)])),
mal.list([])
);
}
case 'vector': {
return ast.values.reduce(
(a: MalVector, b: MalType) => mal.vector(a.values.concat([EVAL(b, env)])),
mal.vector([])
);
}
That looks awfully repetitious doesn't it? If there were a third
instance of that code, for another sequence–related data type say,
then it would absolutely warrant factoring out. However I'm doing
this for fun and education so I refactored it anyway with the help of
typeof and some under the covers parametric typing to look like this:
case 'list':
case 'vector': {
const valGen = mal[ast.type]
const seed = valGen([])
return ast.values.reduce(
(a: typeof seed, b: MalType) => valGen(a.values.concat([EVAL(b, env)])),
seed
)
}
There we have valGen as our type–safe value generator for the
relevant Mal type. Then we aggregate to a new value using the typeof
the seed value, as seen in the reduce function signature type and
the TypeScript checker is perfectly happy with the situation and
what's more the tests all pass. Perhaps not the most effective usage
of the typeof type operator, using MalList | MalVector in its
stead would also suffice, but it is more concise and, one might even
say, apt.
Conclusion
The type system in TypeScript continues to impress me, even when I
think I don't need to be impressed any more. I've also been tangling
with Maps behind the scenes, but that won't get interesting until
the Mal maps need to properly function (maybe not until Step 9: Try?).
And one must not get too distracted when the next step,
Step 4: If Fn Do, is right around the corner—the step where we add
functions to the functional programming language!
Footnotes
1 Thanks to Marius Schulz's blog entry Nullish Coalescing: The ?? Operator in TypeScript for turning up in search results! Searching for an equivalent, or even the literal operator name, in the official handbook didn't get me anywhere but I knew that something like that existed in TypeScript.
2 I think my ideal JavaScript would be a combination of CoffeeScript's expressive syntax with TypeScript's gradual typing. Maybe I just want to learn Elm finally?