I am building a Typescript guard that restricts content from going through certain paths or APIs within a codebase.
The idea being the compiler should reject input if it or any part of its content has been marked as restricted.
This works surprisingly well in the majority of my test cases but unfortunately completely fails when the restricted content is part of a deeply nested object eg: {grandparent: {parent: {child: myRestrictedContent}}}
I have attempted multiple approaches to recursive checking within the RestrictedContentGuard
all without success.
I have replicated the issue in a TS playground which nicely showcases the point of failure, alternatively I have included the same implementation below:
export type RestrictedContent<T> = T & { __opaque__: 'restrictedContent' };
export type DeepRestrictedContent<T> = T extends Array<infer U>
? ReadonlyArray<DeepRestrictedContent<U>>
: T extends object
? RestrictedContent<{[p in keyof T]: DeepRestrictedContent<T[p]>}>
: RestrictedContent<T>;
// How to make this recursively search the Type?
export type RestrictedContentGuard<T> = T extends RestrictedContent<T>
? never
: {[key in keyof T]: T[key] extends RestrictedContent<T[key]> ? never : T[key]};
// Function should not allow any object or property marked as content through.
function myProcessor<T>(content: RestrictedContentGuard<T>) {
console.log(content);
}
// Marked as Restricted Content, no part of the object object graph is allowed to be processed.
type Person = DeepRestrictedContent<{firstName: string, lastName: string, age: number}>;
const myPerson = {firstName: 'Bob', lastName: 'Banana', age: 0.5} as Person;
// Test 1 - [PASSED]: Using the restricted content
myProcessor(myPerson);
// Test 2 - [PASSED]: Using the restricted contents properties
myProcessor(myPerson.firstName);
myProcessor(myPerson.lastName);
// Test 3 - [PASSED]: Using the restricted content within a surrogate
myProcessor({content: myPerson});
// Test 4 - [FAILED]: Using the restricted content within a deeply nested structure
myProcessor({nest: {nest: {nest: myPerson}}});
// TODO: Understand how to make the restricted content guard recursive or walk through the graph of values
It is worth mentioning I have omitted parts of the implementation that deals with both arrays and null referencing in order to narrow in on the problem.
Thank you for reading
Given your definitions, I'd first factor out your type brand to its own name, for east of reference later:
type RestrictMarker = { __opaque__: 'restrictedContent' };
export type RestrictedContent<T> = T & RestrictMarker;
Then instead of checking somethhing like T extends RestrictedContent<T>
, we can just check T extends RestrictMarker
, which amounts to pretty much the same thing, but is more straightforward.
Next, I think it makes sense to first build a detector to see if any property or subproperty has that RestrictMarker
in it. Here's one way to do that:
type HasRestricted<T, Y = unknown, N = never> = T extends RestrictMarker ? Y :
unknown extends { [K in keyof T]: HasRestricted<T[K]> }[keyof T] ? Y : N;
The idea is that HasRestricted<T, Y, N>
will evaluate to Y
if and only if T
is restricted somewhere, and evaluate to N
otherwise. The Y
and N
are not strictly necessary here, but sometimes it's helpful to choose what happens in each case. The defaults are the top type unknown
for Y
and the bottom type never
for N
.
It's a recursive conditional type which immediately evaluates to Y
if T
is itself a RestrictMarker
-compatible type. Otherwise, it recurses, generating the union of HasRestricted<T[K]>
for all properties T[K]
of T
. The union of unknown
with any type X
will be unknown
, while the union of never
with any type X
will be X
. That means the union of all HasRestricted<T[K]>
will only be never
if no subtree is restricted, and otherwise it will be unknown
. So the check unknown extends ⋯
will pass if any of the subtrees are restricted, and will fail only if no subtrees are restricted. Hence if the check passes we return Y
, and only if it fails do we return N
.
Note that it's possible for there to be edge cases where this detector doesn't do what you like (deeply recursive conditional types always seem to have such edge cases), but hopefully one can tweak this to work in such a situation.
And now we're ready to write RestrictedContentGuard<T>
. There are a number of ways to write this, but the one that changes your approach the least (and leaves it as T extends ⋯ ? ⋯ : ⋯
for inference) is:
type RestrictedContentGuard<T> = T extends HasRestricted<T> ? never : T;
The check T extends HasRestricted<T>
looks like T extends unknown
if it's restricted, which will pass. Or like T extends never
if it's not restricted, which will fail (unless T
happens to be never
, but we don't have to worry about that, really). Okay, let's test it:
myProcessor(myPerson); // error
myProcessor(myPerson.firstName); // error
myProcessor(myPerson.lastName); // error
myProcessor({ content: myPerson }); // error
myProcessor({ nest: { nest: { nest: myPerson } } }); // error
myProcessor({ other: "abc" }); // okay
Looks good. myProcessor()
rejects anything with a RestrictMarker
anywhere in its descendant properties, while it accepts a regular object type like {other: string}
.