最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

typescript - How to correctly infer dependent properties in recursive TS types - Stack Overflow

programmeradmin2浏览0评论

Context

I am writing a schema library, where users can define a relationship between tables.

For example:

  • Users can define tables posts, users
  • Users can define a relationship: posts have one owner, which point to users.

Here's how that looks:

const s = schema({
  posts: {
    attrs: {
      title: 'string'
    }, 
    forwardLinks: {
      owner: {
        // ---> intelisense works here
        to: 'posts',
        cardinality: 'one',
      }
    },
  },
  users: {
    attrs: {
      email: 'string'
    },
    forwardLinks: {},
  }
})

And here's what I have to make that work:

type Cardinality = 'many' | 'one';

type AttrType = 'string' | 'number';

interface ISchema { 
  [table: string]: { 
    attrs: any; 
    forwardLinks: any;
  }
}

type ForwardLink<Namespace> = {
  to: Namespace, 
  cardinality: Cardinality
}

type Entity<S extends ISchema> = {
  attrs: {
    [label: string]: AttrType
  },
  forwardLinks: {
    [label: string]: ForwardLink<keyof S>
  },
}

function schema<S extends {
  [table: string]: Entity<S>
}>(s: S) {
  return s;
}

Playground Link

Goal

I want to add a feature called reverseLinks.

Right now we say that posts has one owner. But, I also want to say:

  • users have many posts, through posts.owner.

This is what the new relationship would look like:

const s = schema({
  posts: {
    attrs: {
      title: 'string'
    }, 
    forwardLinks: {
      owner: {
        to: 'posts',
        cardinality: 'one',
      }
    },
  },
  users: {
    attrs: {
      email: 'string'
    },
    forwardLinks: {},
    reverseLinks: {
      ownedPosts: {
        to: 'posts', 
        through: 'owner', 
        cardinality: 'many'
      }
    }
  }
})

Problem

But, I can't seem to get typescript to infer through. Here's what I have so far:

type Cardinality = 'many' | 'one';

type AttrType = 'string' | 'number';

type ForwardLink<Namespace> = {
  to: Namespace, 
  cardinality: Cardinality
}

interface ISchema { 
  [table: string]: { 
    attrs: any; 
    forwardLinks: any;
    reverseLinks: any;
  }
}

type ReverseLink<S extends ISchema, Namespace extends keyof S> = {
  to: Namespace; 
  cardinality: Cardinality;
  through: keyof S[Namespace]['forwardLinks']
}

type Entity<S extends ISchema> = {
  attrs: {
    [label: string]: AttrType
  },
  forwardLinks: {
    [label: string]: ForwardLink<keyof S>
  },
  reverseLinks: {
    [label: string]: ReverseLink<S, keyof S>
  }
}

function schema<S extends {
  [table: string]: Entity<S>
}>(s: S) {
  return s;
}

const s = schema({
  posts: {
    attrs: {
      title: 'string'
    }, 
    forwardLinks: {
      owner: {
        to: 'users',
        cardinality: 'one',
      }
    },
    reverseLinks: {},
  },
  users: {
    attrs: {
      email: 'string'
    },
    forwardLinks: {},
    reverseLinks: {
      ownedPosts: {
        to: 'posts',
        cardinality: 'many', 
        // ERROR Type 'string' is not assignable to type 'never'.(2322)
        // Expected: 'owner'
        through: 'owner'
      }
    }
  }
})

Playground Link

How would you approach this?

Context

I am writing a schema library, where users can define a relationship between tables.

For example:

  • Users can define tables posts, users
  • Users can define a relationship: posts have one owner, which point to users.

Here's how that looks:

const s = schema({
  posts: {
    attrs: {
      title: 'string'
    }, 
    forwardLinks: {
      owner: {
        // ---> intelisense works here
        to: 'posts',
        cardinality: 'one',
      }
    },
  },
  users: {
    attrs: {
      email: 'string'
    },
    forwardLinks: {},
  }
})

And here's what I have to make that work:

type Cardinality = 'many' | 'one';

type AttrType = 'string' | 'number';

interface ISchema { 
  [table: string]: { 
    attrs: any; 
    forwardLinks: any;
  }
}

type ForwardLink<Namespace> = {
  to: Namespace, 
  cardinality: Cardinality
}

type Entity<S extends ISchema> = {
  attrs: {
    [label: string]: AttrType
  },
  forwardLinks: {
    [label: string]: ForwardLink<keyof S>
  },
}

function schema<S extends {
  [table: string]: Entity<S>
}>(s: S) {
  return s;
}

Playground Link

Goal

I want to add a feature called reverseLinks.

Right now we say that posts has one owner. But, I also want to say:

  • users have many posts, through posts.owner.

This is what the new relationship would look like:

const s = schema({
  posts: {
    attrs: {
      title: 'string'
    }, 
    forwardLinks: {
      owner: {
        to: 'posts',
        cardinality: 'one',
      }
    },
  },
  users: {
    attrs: {
      email: 'string'
    },
    forwardLinks: {},
    reverseLinks: {
      ownedPosts: {
        to: 'posts', 
        through: 'owner', 
        cardinality: 'many'
      }
    }
  }
})

Problem

But, I can't seem to get typescript to infer through. Here's what I have so far:

type Cardinality = 'many' | 'one';

type AttrType = 'string' | 'number';

type ForwardLink<Namespace> = {
  to: Namespace, 
  cardinality: Cardinality
}

interface ISchema { 
  [table: string]: { 
    attrs: any; 
    forwardLinks: any;
    reverseLinks: any;
  }
}

type ReverseLink<S extends ISchema, Namespace extends keyof S> = {
  to: Namespace; 
  cardinality: Cardinality;
  through: keyof S[Namespace]['forwardLinks']
}

type Entity<S extends ISchema> = {
  attrs: {
    [label: string]: AttrType
  },
  forwardLinks: {
    [label: string]: ForwardLink<keyof S>
  },
  reverseLinks: {
    [label: string]: ReverseLink<S, keyof S>
  }
}

function schema<S extends {
  [table: string]: Entity<S>
}>(s: S) {
  return s;
}

const s = schema({
  posts: {
    attrs: {
      title: 'string'
    }, 
    forwardLinks: {
      owner: {
        to: 'users',
        cardinality: 'one',
      }
    },
    reverseLinks: {},
  },
  users: {
    attrs: {
      email: 'string'
    },
    forwardLinks: {},
    reverseLinks: {
      ownedPosts: {
        to: 'posts',
        cardinality: 'many', 
        // ERROR Type 'string' is not assignable to type 'never'.(2322)
        // Expected: 'owner'
        through: 'owner'
      }
    }
  }
})

Playground Link

How would you approach this?

Share Improve this question edited Nov 19, 2024 at 18:32 Stepan Parunashvili asked Nov 19, 2024 at 0:10 Stepan ParunashviliStepan Parunashvili 2,8456 gold badges35 silver badges57 bronze badges 4
  • The second playground link is corrupted. – jcalz Commented Nov 19, 2024 at 3:38
  • (see prev comment) I'd say your problem is that ReverseLink<S, N> needs to distribute over unions in N. Right now you're asking for properties common to all S[N]['forwardLinks'] but those are unlikely to be anything but never. If you make ReverseLink a distributive object type as shown in this playground link then you get more reasonable behavior. Note that such recursive inference is likely to eventually fail you, so you might immediately hit another problem. But, does this address the current question? If so, I'll write an answer; if not, what's missing? – jcalz Commented Nov 19, 2024 at 3:47
  • This works great, thank you @jcalz! Would appreciate if you wrote the answer. If you have the bandwidth, I would love more explanation on 'such recursive inference is likely to eventually fail you, so you might immediately hit another problem. ' – Stepan Parunashvili Commented Nov 19, 2024 at 18:33
  • I don't have more general explanation without an example; I'm saying... TS's inference algorithm has limitations, and the more complicated your requirements, the more chance of hitting those limits. You might find that at some point you need to give up on inference and start annotating types. But that's out of the scope of the question. I'm just trying to forestall the: "yes, this works, it allowed me to make progress until I hit something else five minutes later, which is now a showstopper for me". Other than intuition I don't have anything more concrete to say about it. – jcalz Commented Nov 19, 2024 at 19:10
Add a comment  | 

2 Answers 2

Reset to default 1

It looks like the problem here is that ReverseLink<S, N> is not distributive over unions in N. If you write ReverseLink<S, N1 | N2>, you'd like it to be equivalent to ReverseLink<S, N1> | ReverseLink<S, N2> so that the through property always corresponds to the specific to property. As it is now, ReverseLink<S, N1 | N2> gives you a through property like keyof S[N1 | N2]['forwardLinks'], which will end up being keyof (S[N1]['forwardLinks'] | S[N2]['forwardLinks']). But the keyof operator is contravariant in its operand (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript), so keyof (X | Y) is equivalent to keyof X & keyof Y. (See Is it possible to get the keys from a union of objects?) and the union of types corresponds to an intersection of keys, and if no keys are shared then it becomes the impossible never type, as you saw.

There are a few ways to make types distributive over unions in TypeScript. When the type to distribute over is keylike, I tend to prefer a distributive object type as coined in microsoft/TypeScript#47109, which is just a mapped type over each member of the union into which you index with the full union:

type ReverseLink<S extends ISchema, N extends keyof S> = { [K in N]:
  {
    to: K;
    cardinality: Cardinality;
    through: keyof S[K]['forwardLinks']
  }
}[N]

You can verify that if N is a single key, then it's the same as your old version, whereas if N is a union (like keyof S) then this becomes a union of ReversLink<K> for each K in N.

Now let's test out your example:

const s = schema({
  posts: {
    attrs: { title: 'string' },
    forwardLinks: { owner: { to: 'users', cardinality: 'one', } },
    reverseLinks: {},
  },
  users: {
    attrs: { email: 'string' },
    forwardLinks: {},
    reverseLinks: {
      ownedPosts: {
        to: 'posts', cardinality: 'many',
        through: 'owner' // okay
      }
    }
  }
})

Now it works; the type of through is seen as having to be "owner". You can also verify that this works to correlate to with through, so you can't mix them up:

const t = schema({
  a: {
    attrs: {}, forwardLinks: {
      x: { to: 'c', cardinality: 'one' }
    }, reverseLinks: {}
  },
  b: {
    attrs: {}, forwardLinks: {
      y: { to: 'c', cardinality: 'one' }
    }, reverseLinks: {}
  },
  c: {
    attrs: {}, forwardLinks: {}, reverseLinks: {
      ax: { cardinality: 'many', to: 'a', through: 'x' }, // okay
      by: { cardinality: 'many', to: 'b', through: 'y' }, // okay
      ay: { cardinality: 'many', to: 'a', through: 'y' } // error!      
    }
  }
})

Playground link to code

Try to define type of through?

type ReverseLink<S extends ISchema, Namespace extends keyof S> = {
  to: Namespace; 
  cardinality: Cardinality;
  through?: string
}
发布评论

评论列表(0)

  1. 暂无评论