1

I have a bunch of typescript types which use a shared Inaccessible interface in place of objects that the user doesn't have access to.

However, oftentimes the types I'm working with are more complicated than the above (nested data from a GQL endpoint) and the components I'm writing only work if the entire object is defined. Since this is fairly common I'd love to be able to write a typeguard which I could use in any component and would recursively "remove" all Inaccessible types from all descendants of the base type.

Here is an example of what I'm trying to do. But crucially the AllPropertiesAccessible<T> type would be generic so it would work with other types with Inaccessible descendants.

interface Inaccessible {
  invalidId: string;
}

interface Person {
  id: string;
  name: string;
}

interface Node {
  children: Array<Node | Inaccessible>;
  user: Person | Inaccessible;
  spouse: Person | Inaccessible;
}

interface FamilyTree {
  rootNode: Node | Inaccessible;
}

function isFamilyTreeAccessible(familyTree: FamilyTree) familyTree is AllPropertiesAccessible<FamilyTree> {
  // logic which recursively validates that all elements in the tree are accessible
}

function FamilyTreeOrInaccessibleDisplay({familyTree}: {familyTree: FamilyTree}): JSX.Element {
  if (isFamilyTreeAccessible(familyTree)) {
    return <FamilyTreeDisplay familyTree={familyTree} />
  } else {
    return <NotAccessibleMessage />
  }
}

function FamilyTreeDisplay({familyTree}: {familyTree: AllPropertiesAccessible<FamilyTree>}) {}

function NotAccessibleMessage() {
  return <div>Inaccessible</div>

In this example we have a FamilyTree type which has a bunch of descendants that could be inaccessible. The AllPropertiesAccessible<FamilyTree> type would be equivalent to:

interface AccessibleNode {
  children: Array<AccessibleNode>;
  user: Person;
  spouse: Person;
}

interface AccessibleFamilyTree {
  rootNode: AccessibleNode;
}

Is there a way to write such a AllPropertiesAccessible type in typescript?

  • Please [edit] to make your code a self-contained [mre] that demonstrates the issue when pasted as-is into a standalone IDE. Right now I'm faced with undeclared `UserEmail`, `Inaccessible`, `useGrapQL`, `QUERY`, `QueryResult`, etc. To proceed I'd have to define them myself and hope that my choices are close enough to what you're doing. Could you make this a [mre] including what you expect `AllPropertiesAccessible` to do to various choices for `T` that range over the use cases you care about? Thanks! – jcalz Feb 02 '23 at 01:13
  • @jcalz - updated the summary with a fairy robust example of what I expect. – hijodelsol14 Feb 02 '23 at 04:54
  • So does [this approach](https://tsplay.dev/mZQlaN) meet your needs? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Feb 02 '23 at 14:11
  • Looks mostly like what I need. Though unless I'm missing something it doesn't seem to be reducing the `Array` to `Array` for the `children` prop of `FamilyTree`? – hijodelsol14 Feb 02 '23 at 17:50
  • No, it's definitely reducing it; the fact that `x` and `y` are mutually assignable indicates that your manually defined type and the generated type are structurally equivalent. Maybe [this](https://tsplay.dev/mqe4qm) makes it more obvious when you drill down into `children` and look at its type? Maybe what you're missing is that the type in question is an anonymous recursive type so the type display will eventually write `...` somewhere. When you did it manually you did it in two steps with an intermediate named type, but the type function I wrote doesn't have the luxury of doing that. – jcalz Feb 02 '23 at 17:58
  • Do you agree that it's what your need or am I still missing something? – jcalz Feb 02 '23 at 17:58
  • Yup this looks like what I need then! – hijodelsol14 Feb 03 '23 at 04:53

1 Answers1

1

Given your example code, my inclination would be to define AllPropertiesAccessible as the following recursive conditional type:

type AllPropertiesAccessible<T> =
    T extends Inaccessible ? never :
    { [K in keyof T]: AllPropertiesAccessible<T[K]> }

It's a distributive conditional type, so it splits T up into its union members; any of them which are Inaccessible will be removed (since they evaluate to the impossible never type), and those which are not removed are mapped so any properties are themselves transformed with AllPropertiesAccessible<>.

This definition is deceptively simple, since you may wonder what happens if T is a primitive type like string (do all its apparent properties like length get mapped?) or if T is an array (do all its array methods and properties like length get mapped?). Luckily, since {[K in keyof T]: AllPropertiesAccessible<T[K]>} is a homomorphic mapped type (see What does "homomorphic mapped type" mean? ) it automatically leaves primitives alone (so AllPropertiesAccessible<string> will be string) and automatically maps arrays/tuples to arrays/tuples) (so AllPropertiesAccessible<T[]> will be the same as AllPropertiesAccessible<T>[]).


Let's test it out:

type AccessibleFamilyTree = AllPropertiesAccessible<FamilyTree>;
/* type AccessibleFamilyTree = {
rootNode: {
    children: ...[];
    user: {
        id: string;
        name: string;
    };
    spouse: {
        id: string;
        name: string;
    };
};
} */

That looks good, although the type display for the children property of rootNode is a bit confusing. What does ...[] mean?

Well, the issue is that because FamilyTree is a recursive type, AccessibleFamilyTree is also recursive. But the AllPropertiesAccessible<T> type doesn't give new names to intermediate types generated by the transformation. Indeed, if you were to write out the intended type yourself, you'd find yourself reaching for a new named type:

interface AccessibleFamilyTreeManual {
    rootNode: AccessibleNode;
}

// need to name this
interface AccessibleNode {
    children: Array<AccessibleNode>;
    user: Person;
    spouse: Person;
}

If you try to keep AccessibleNode anonymous, you end up with infinite regress. The compiler handles this by just giving up when displaying the type. It still knows what it is though:

declare let aft: AccessibleFamilyTree;
const foo = aft.rootNode.children.map(x => x.user.name)
// ---------------------------------> ^
// (parameter) x: {  
//   children: ...[]; 
//   user: { id: string; name: string; }; 
//   spouse: { id: string; name: string; }; 
// }
// const foo: string[]

The type of each element of aft.rootNode.children is known to itself have a children of some recursive array type, as well as user and spouse properties of the right shape.

And just to be sure that AccessibleFamilyTree and AccessibleFamilyTreeManual are seen to be equivalent, let's do a little assignment between them:

declare let aftm: AccessibleFamilyTreeManual;
aft = aftm; // okay
aftm = aft; // okay

The face that those assignments succeed is evidence that the compiler thinks that their types are mutually assignable, as desired.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360