Issue
I am attempting to define a typed array of objects. The objects sharing the same generic type structure and the generic type accepting a type argument to provide more specificity (constraining the allowed values of the object's other (array) attribute).
Example
A typed array should enforce its elements to adhere to rules like:
{ tableName: 'roles', ...}
should constrain returnColumns
to have only 'id' and/or 'name'
and
{ tableName: 'permissions', ...}
should constrain returnColumns
to have only 'id' and/or 'permission'
e.g.
[
{
tableName: 'roles',
returnColumns: ['id', 'name'] // should be OK
},
{
tableName: 'roles',
returnColumns: ['name'] // should be OK
},
{
tableName: 'permissions',
returnColumns: ['id', 'permission'] // should be OK
},
{
tableName: 'permissions',
returnColumns: ['id'] // should be OK
},
{
tableName: 'roles',
returnColumns: ['id', 'permission'] // should not be OK, permission belongs to permissions
},
]
Breakdown
Part 1: working typed objects
In the example below, the single objects are correctly typed:
type JoinTable<T> = {
tableName: T;
returnColumns: TableColumns<TypeOfTableName<T>>[];
};
const rolesExample1: JoinTable<'roles'> = {
tableName: 'roles',
returnColumns: ['id', 'name'] // OK, because roles is only allowed id and/or name
};
const rolesExample2: JoinTable<'roles'> = {
tableName: 'roles',
returnColumns: ['id', 'permission'] // Should fail, because roles is only allowed id and name, and permission is not available to roles
};
const permsExample3: JoinTable<'permissions'> = {
tableName: 'permissions',
returnColumns: ['id', 'permission'] // OK, because permissions is only allowed id and/or permission
};
const permsExample4: JoinTable<'permissions'> = {
tableName: 'permissions',
returnColumns: ['id', 'name'] // Should fail, because permissions is only allowed id and/or permission, and name is not available to permissions
};
Part 2: failing typed array of objects
In the example below, we see the failure:
type JoinTable<T> = {
tableName: T;
returnColumns: TableColumns<TypeOfTableName<T>>[];
};
type JoinColumns<T> = T extends unknown
? TableColumns<TypeOfTableName<T>>
: never;
// This code allows for returnColumns elements to be any value of any table
export type Joins<T extends TableName> = {
joins: (Omit<JoinTable<RelatedTableName<T>>, 'returnColumns'> & {
returnColumns: JoinColumns<RelatedTableName<T>>[]
})[]
};
const combinedJoins: Joins<'role_permissions'> = {
joins: [
{
tableName: 'roles',
returnColumns: ['id', 'name', 'permission'] // Should (but doesn't) fail, because permission is not available to roles
},
{
tableName: 'permissions',
returnColumns: ['id', 'name', 'permission'] // Should (but doesn't) fail, because name is not available to permissions
},
{
tableName: 'roles',
returnColumns: ['id', 'name', 'uh-oh'] // Should fail (and does) because nothing supports uh-oh
},
{
tableName: 'roles',
returnColumns: ['id', 'name'] // should be OK
},
{
tableName: 'roles',
returnColumns: ['name'] // should be OK
},
{
tableName: 'permissions',
returnColumns: ['id', 'permission'] // should be OK
},
]
};
Part 3: where it started
I originally started with the following variation of the Joins type:
export type Joins<T extends TableName> = {
joins: JoinTable<RelatedTableName<T>>[]
};
This worked when there was only one object type in the array, but with two object types, suddenly the values of returnColumns is constrained to only those values which all the objects in the array share.
e.g. if there was both an roles and a permissions object in the array, the values of returnColumns could only be id which both types have in common.
The data schema and supporting types: Database, TableName, TableColumns, TableRelationships etc
export interface Database {
golf_scores: {
Tables: {
permissions: {
Row: {
id: number;
permission: string;
};
Relationships: [];
};
role_permissions: {
Row: {
id_permissions: number;
id_roles: number;
};
Relationships: [
{
referencedRelation: 'permissions';
referencedColumns: ['id'];
},
{
referencedRelation: 'roles';
referencedColumns: ['id'];
}
];
};
roles: {
Row: {
id: number;
name: string;
};
Relationships: [];
};
};
}
}
export type TableName = keyof Database['golf_scores']['Tables'];
export type TableColumns<T extends TableName> =
keyof Database['golf_scores']['Tables'][T]['Row'];
export type TypeOfTableName<T> = T extends TableName ? T : never;
type TablesRelationships<T extends TableName> =
Database['golf_scores']['Tables'][T]['Relationships'];
export type RelatedTableName<T extends TableName> =
TablesRelationships<T>[number]['referencedRelation'];
(Note: working examples here: (typescriptlang.org)
Final comments
So far, it only seems possible to have all values from all tables, or only values from all tables that are common amongst all tables. I have been exploring solutions which seemed somewhat similar: Typescript, merge object types? but I'm starting to wonder if what I'm attempting is possible?
Solution
The problem is that in
type Joins<T extends TableName> = {
joins: (Omit<JoinTable<RelatedTableName<T>>, 'returnColumns'> & {
returnColumns: JoinColumns<RelatedTableName<T>>[]
})[]
};
the type of the joins
property does not distribute over unions in RelatedTableName<T>
. Let's split that definition into some pieces for ease of discussion. Your code is equivalent to:
type Joins<T extends TableName> = {
joins: J<RelatedTableName<T>>
}
type J<R> = (Omit<JoinTable<R>, 'returnColumns'> & {
returnColumns: JoinColumns<R>[]
})[]
When R
is a union type like R1 | R2 | R3
, you really want J<R>
to also be a union type like J<R1> | J<R2> | J<R3>
. That's what "distribute over unions" means. But that's not how J<R>
works. The Omit
utility type doesn't distribute over unions, and even if it did, it wouldn't distribute as a single chunk along with & { returnColumns: JoinColumns<R>[] }
.
Luckily it's pretty easy to turn a non-distributive type into a distributive one. You can either write a distributive conditional type like
type DistribJ<R> = R extends unknown ?
(Omit<JoinTable<R>, 'returnColumns'> & {
returnColumns: JoinColumns<R>[]
})[] : never
or, since R
is expected to be keylike, you can write a distributive object type (as coined in ms/TS#47109) where you map over R
and then immediately index into the mapped type:
type DistribJ<R extends PropertyKey> = {
[K in R]: (Omit<JoinTable<R>, 'returnColumns'> & {
returnColumns: JoinColumns<R>[]
})[] }[R]
If you replace J
with either DistribJ
above, your code will start working.
To demonstrate, I'll use the distributive object version and dispense with the intermediate J
type, as follows:
type Joins<T extends TableName> = {
joins: ({ [K in RelatedTableName<T>]: (Omit<JoinTable<K>, 'returnColumns'> & {
returnColumns: JoinColumns<K>[]
}) }[RelatedTableName<T>])[]
};
And now everything behaves as desired. If we examine Joins<'role_permissions'>
(expanding the type as described in How can I see the full expanded contract of a Typescript type?), we get:
type JoinsRolePermissionsExplicit =
ExpandRecursively<Joins<'role_permissions'>>;
/* type JoinsRolePermissionsExplicit = {
joins: ({
tableName: "permissions";
returnColumns: ("id" | "permission")[];
} | {
tableName: "roles";
returnColumns: ("id" | "name")[];
})[];
} */
which is exactly what you want:
const combinedJoins: Joins<'role_permissions'> = {
joins: [
{
tableName: 'roles',
returnColumns: ['id', 'name', 'permission'] // error!
//~~~~~~~~~~~
// Type '("id" | "name" | "permission")[]' is not assignable to
// type '("id" | "name")[] | ("id" | "permission")[]'.
},
{
tableName: 'permissions',
returnColumns: ['id', 'name', 'permission'] // error!
//~~~~~~~~~~~
// Type '("id" | "name" | "permission")[]' is not assignable to
// type '("id" | "name")[] | ("id" | "permission")[]'.(2322)
},
{
tableName: 'roles',
returnColumns: ['id', 'name', 'uh-oh'] // error!
// ~~~~~~~
// Type '"uh-oh"' is not assignable to type '"id" | "name" | "permission"'.
},
{
tableName: 'roles',
returnColumns: ['id', 'name'] // okay
},
{
tableName: 'roles',
returnColumns: ['name'] // okay
}
]
};
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.