Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save madsimian/765ddfdbf32aeeaca05007cdd185da5b to your computer and use it in GitHub Desktop.
Save madsimian/765ddfdbf32aeeaca05007cdd185da5b to your computer and use it in GitHub Desktop.
typescript type guards around closure definitions are ignored by the inner function
// Typescript handles type guards fundamentally differently than it
// handles data in the context of a closure.
//
// This is not new, and was documented at
// https://github.com/microsoft/TypeScript/issues/7662
// and
// https://github.com/microsoft/TypeScript/issues/8552
//
// Demonstration follows.
interface namedThing {
name: string;
val: number;
}
interface myType {
p1: namedThing | null;
p2: number;
}
// [jb]
// Function that will set the property of an object to null after a
// delay. This helps illustrate the difficulty of even a runtime type
// guard giving confidence that the captured context of a closure
// hasn't been mutated.
const delay = 200;
const setNullBomb = function (obj: any, key: string) : void {
// const nullIt = () => obj[key] = null;
// const nullIt = () => delete obj[key];
const nullIt = () => obj[key] = 1;
setTimeout(nullIt, delay);
}
// Given a list of namedThings and an object of myType, return
// a function that will itself return true if the object is in the
// list.
//
// Note: the inner function is gratuitous here, and is present only
// to demonstrate the behavior of data and type guards across the
// closure boundary.
function funcFactory(s: namedThing[], o: myType): () => boolean {
// this doesn't work because o.p1 could be null
//let thingWeAreLookingFor: namedThing = o.p1;
// with a type guard, we're good
let thingWeAreLookingFor: namedThing;
if (o.p1) {
thingWeAreLookingFor = o.p1;
}
// but that same type guarding approach does *not* work
// when we're trying to guard the variable within
// a closure -- the following fails because the compiler
// says that o.p1 might be null, even though it's wrapped
// in the type guard that guarantees otherwise.
// if (o.p1) {
// const innerF = function() {
// let foundIt = false;
// s.forEach( e => {
// if (e.name == o.p1.name) {
// foundIt = true;
// }
// }
// )
// return(true);
// }
// }
// if the type guard is moved inside of the inner function,
// all is well
const innerF = function() {
let foundIt = false;
s.forEach( e => {
if (o.p1) {
console.log(`comparing ${e.name} to ${o.p1.name}`);
if (e.name == o.p1.name) {
foundIt = true;
}
}
})
// [jb] Unbeknownst to the type guard, we can exfiltrate part
// of the closure's conteext and do unsafe things with it
// later. Because it's a callback happening at runtime, it's
// really hard for the compiler to be sure the type guard
// isn't exposed like this, even though the guard is also a
// runtime check. This bomb is effective anywhere in
// funcFactory's scope, including inside the guard.
setNullBomb(o, 'p1');
return(foundIt);
}
return(innerF);
}
let obj = {
p1: { name: "foo", val: 2 },
p2: 2
}
let objList = [
{ name: "bar", val: 2 },
{ name: "foo", val: 3 },
{ name: "obazof", val: 4 },
]
let myFunc = funcFactory(objList, obj);
console.log(`1) The item ${myFunc() ? 'was' : 'was not'} found in the list`);
// changing what we're looking for now won't change myFunc, since it's
// bound to the values present when the closure was created
// [jb] Well, it's bound to the values of the *references* when the
// closure is created; because we can capture a reference by enclosing
// the same context in a different closure, we can still mutate the
// value.
// [jb] The next assignment to `obj` doesn't change anything in the
// scope of the innerF function, so that function still returns true.
// N.B. `innerF`'s closure didn't capture a reference to `obj`, it
// captured a reference to `o` inside funcFactory, which took its
// value from `obj` when called.
// Because no previously enclosed context contains a reference to the
// obj identifier, and the assignment here creates a *new* object with a
// new p1 value, it doesn't affect myFunc();
obj = {
p1: { name: "food", val: 2 },
p2: 2
}
console.log(`2) The item ${myFunc() ? 'was' : 'was not'} found in the list`);
// To show how a type guard just can't provide assurance of type
// safety to a variable in a closure, we wait 100ms longer than the
// time bomb's delay, then check on return value of myFunc.
const logMe = () => console.log(`3) The item ${myFunc() ? 'was' : 'was not'} found in the list`);
setTimeout(logMe, delay + 100);
// Obviously the fact that changing obj's value after creating myFunc has no
// impact on the value of myFunc is not surprising - this is the core of
// makes the closure a closure.
//
// [jb] in more deeply functional language (say *cough* Clojure
// *cough*), closures would capture values, and that would be
// that.
//
// But the point of the more drawn out example is to demonstrate that data and
// type guards behave differently across the closure's scope boundary. IOW,
// tsc logically *could* let the type guard defined in the outer scope "work"
// in the inner scope, it just chooses not to.
// [jb] I think type guards work the same way at runtime, it's just
// the compiler that has a blind spot when it comes to closures and
// their risk to type safety. Normally the compiler sees a type guard
// expression, understands the intent, can agree on the intended
// safety of the inner block. If the block is a closure, the
// possibility of context capture and mutation in another scope that's
// not statically analyzable by the compiler means the Typescript team
// decided to let a warning stand in the place of this
// possibility.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment