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 oneowner
, which point tousers
.
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 manyposts
, throughposts.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 oneowner
, which point tousers
.
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 manyposts
, throughposts.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 |2 Answers
Reset to default 1It 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
}
ReverseLink<S, N>
needs to distribute over unions inN
. Right now you're asking for properties common to allS[N]['forwardLinks']
but those are unlikely to be anything butnever
. If you makeReverseLink
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