Sorry for the somewhat vague name, I wasn't really sure how to explain it concisely. Basically, I am using a library called zapatos, though I've created an example independent of that library that reproduces the error. It is a library that models database tables and whatnot in a particular way, and I am struggling with how to "prove" relationships between tables, when they exist.
Here is a stripped down version of how it is defining some tables and their columns. Below, I will have the function I'm trying to actually implement.
// this cannot change -- this is how the library (zapatos in this case) defines things
namespace table_a {
export type Table = 'table_a';
export interface Selectable {
random1: number;
}
}
namespace table_b {
export type Table = 'table_b';
export interface Selectable {
field1: number;
}
}
namespace table_c {
export type Table = 'table_c';
export interface Selectable {
field2: number;
}
}
namespace table_b_source {
export type Table = 'table_b_source';
export interface Selectable {
field1: number;
common: string;
}
}
namespace table_c_source {
export type Table = 'table_c_source';
export interface Selectable {
field2: number;
common: string;
}
}
type Table =
| table_a.Table
| table_b.Table
| table_c.Table
| table_b_source.Table
| table_c_source.Table;
type SelectableForTable<T extends Table> = {
table_a: table_a.Selectable;
table_b: table_b.Selectable;
table_c: table_c.Selectable;
table_b_source: table_b_source.Selectable;
table_c_source: table_c_source.Selectable;
}[T];
and my goal is to make the following class work:
// This we have more flexibility with
type Subset = Extract<Table, 'table_b' | 'table_c'>;
class Thing<T extends Subset> {
// how can I get this to work?
convert(obj: SelectableForTable<T>, common: string): SelectableForTable<`${T}_source`> {
return {
...obj,
common,
};
}
}
I've tried a bunch of different approaches, but I haven't been able to get it to work. I get the error
Type 'SelectableForTable<T> & { common: string; }' is not assignable to type 'SelectableForTable<`${T}_source`>'.
Type 'SelectableForTable<T> & { common: string; }' is not assignable to type 'table_b_source.Selectable & table_c_source.Selectable'.
Type 'SelectableForTable<T> & { common: string; }' is not assignable to type 'Selectable'
I'm not sure how to get it to understand T extends Subset
and make that properly narrow down the tables, which then should let it understand that for a given T in Subset, T
and T_source
are in fact related.
I welcome alternative approaches, though the general idea is like this...we want to be able to specify a subset of tables (having been defined in this way), and for those tables, we know that there is a table T and a table T_source, and they will all vary by a set number of fields. The types all reflect this reality, but I can't get the compiler to understand it.
The equivalence of SelectableForTable<T> & { common: string }
and SelectableForTable<`${T}_source`>>
for arbitrary generic T extends Subset
requires some sort of higher-order analysis that TypeScript does not perform. When you write a single block of code, TypeScript analyzes its types once. But to verify the equivalence above, one would need to "plug in" all the different possible instantiations of T
, and then check if the equivalence holds. That is, it would have to consider multiple hypotheticals like "if T
is "table_b"
then it's fine", followed by "if T
is "table_c"
then it's fine," (and possibly combinations of things like "if T
is the union "table_b" | "table_c"
" or "if T
is the never
type",) which doesn't happen. It's good that it doesn't do that, because it would mean terrible performance for any code with generics and unions. At one point I had suggested the possibility to ask TypeScript to do such analysis on an opt-in basis, at microsoft/TypeScript#25051, but it was not adopted (because it would make the language more complicated for marginal benefit).
Generally speaking, when you try to represent higher order correlations among types, you will either need to give up and just assert that the correlation exists, or you need to refactor into one of the few forms where TypeScript can see the correlation directly as an operation on a single generic type. The supported refactoring for the general case is described in microsoft/TypeScript#47109.
But for your example I'd probably just do the simplest thing that works, which is to forget entirely about your *_source
namespaces and just define them programmatically in terms of the regular namespaces. Like
type SelectableForTableSource<T extends Subset> =
SelectableForTable<T> & { common: string };
class Thing<T extends Subset> {
convert(obj: SelectableForTable<T>, common: string): SelectableForTableSource<T> {
return {
...obj,
common,
};
}
}
This works, but it means that it really no longer matters what's inside table_b_source
or table_c_source
. You could remove those entirely.
If you need them to exist, then you might want to write some sort of utility type that acts as a sentry and complains if and only if the *_source
namespaces diverge from what SelectableForTableSource
thinks they are:
// verification if needed
type MutuallyExtends<T, U> = [T] extends [U] ? [U] extends [T] ? true : false : false
type TestExtends<T, U extends T> = void;
type TestSource = TestExtends<Record<string, true>, {
[K in Subset]: MutuallyExtends<
SelectableForTableSource<K>,
SelectableForTable<`${K}_source`>> }>
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// does this line compile? then everything is okay
// if not, then you have a problem with at least one of your _source namespaces
This will compile if and only if SelectableForTableSource<K>
is mutually assignable with SelectableForTable<`${K}_source`>>
for all K
in Subset
. Mutual assignability is checked in MutallyExtends
, producing true
or false
. Then we make a mapped type for each K
in Subset
, producing something like {table_b: true, table_c: true}
. If that mapped type is assignable to Record<string, true>
, then everything's fine. Otherwise at least one entry is false
and that means you have a problem.
The type TestExtends<T, U>
is the sentry type, as it will only compile if U extends T
, because that is a constraint on the U
type argument. It doesn't matter what TextExtends<T, U>
evaluates to (it's just void
here). It only matters whether the line compiles or fails to compile.
You can test it out by, say, changing something to be incompatible:
namespace table_b_source {
export type Table = 'table_b_source';
export interface Selectable {
field7: number; // <-- oops
common: string;
}
}
and then you get an error like
type TestSource = TestExtends<Record<string, true>, { // error!
[K in Subset]: MutuallyExtends< // error!
SelectableForTableSource<K>, // error!
SelectableForTable<`${K}_source`>> }> // error!
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Type '{ table_b: false; table_c: true; }' does not satisfy the
// constraint 'Record<string, true>'. Property 'table_b' is incompatible
meaning that you need to check table_b
and fix it.