Reputation: 320
I'm trying to create an overloaded function that should have a different signature depending on whether the type it is returning has the property company_id
.
If the function's return type includes the property company_id
, the function's company_id
parameter is mandatory.
If the function's return type does not include the property company_id
, the function's company_id
is not only not-mandatory, passing it when calling the function should be a type error.
Below is my solution, it's working but is verbose and involves much type wrangling.
type Data = Record<string, unknown> & { company_id?: never };
type DataWithCompany = Data & { company_id: number };
type SelectById = {
<D extends { company_id: number }, K extends keyof D, V extends D[K]>(
table: string,
idKey: K,
idVal: V,
company_id: number
): Promise<D>;
<D extends Data, K extends keyof D, V extends D[K]>(
table: string,
idKey: K,
idVal: V,
company_id?: never
): Promise<D>;
};
const selectById: SelectById = async (
table: string,
idKey: string,
idVal: unknown,
company_id?: number
) => {
if (company_id) {
return { id: 1, name: "With Company Id", company_id } as DataWithCompany;
} else {
return { id: 2, name: "Without Company Id" } as Data;
}
};
type FooObjectWithCompany = {
company_id: number;
id: number;
name: string;
};
type BarObjectWithoutCompany = {
id: number;
name: string;
};
// Type error:
const thingy: Promise<FooObjectWithCompany> = selectById("footable", "id", 1);
const otherThingy: Promise<BarObjectWithoutCompany> = selectById("bartable", "id", 1, 2);
// No type error:
const thingyWorking: Promise<FooObjectWithCompany> = selectById("footable", "id", 1, 1);
const otherThingyWorking: Promise<BarObjectWithoutCompany> = selectById("bartable", "id", 1);
What is the idiomatic TypeScript way of solving this problem?
Upvotes: 0
Views: 112
Reputation: 187034
This can be achieved with only the most minimal of generics and function overloads.
First, I think you need two generic types here:
type DataWithCompany<Data> = Data & { company_id: number }
type DataWithoutCompany<Data> = Data & { company_id?: never }
Now you can take any object type, and declare it to be with or without a company_id
.
selectById
seems to return an object with id
and name
, so we can declare that type:
interface IdAndName {
id: number
name: string
}
For the functions, you are describing something that fits function overloads. The presence of an additional argument changes the return type. So you have one overload for when the argument exists, and one for when it doesn't.
function selectById(
table: string,
idKey: string,
idVal: unknown,
company_id: number
): DataWithCompany<IdAndName>
function selectById(
table: string,
idKey: string,
idVal: unknown,
): DataWithoutCompany<IdAndName>
Then declare the implementation which needs to have the combined type of both overload signatures.
function selectById(
table: string,
idKey: string,
idVal: unknown,
company_id?: number
): DataWithCompany<IdAndName> | DataWithoutCompany<IdAndName> {
if (company_id) {
return { id: 1, name: "With Company Id", company_id }
} else {
return { id: 2, name: "Without Company Id" };
}
};
Now this produces the error you expect:
type FooObjectWithCompany = {
company_id: number;
id: number;
name: string;
};
// error
const thingy: FooObjectWithCompany = selectById("footable", "id", 1);
However, this does not error:
type BarObjectWithoutCompany = {
id: number;
name: string;
};
// works
const otherThingy: BarObjectWithoutCompany = selectById("bartable", "id", 1, 2);
The reason that's fine is that BarObjectWithoutCompany
has no entry for company_id
, which means that typescript can safely ignore that property entirely. It won't let you access company_id
either way, so it's value doesn't matter. You can often safely assign an object with more properties to a type with less properties, as long as all required properties are present.
You can improve this by declaring these types with the helpers we setup first:
type FooObjectWithCompany = DataWithCompany<IdAndName>;
type BarObjectWithoutCompany = DataWithoutCompany<IdAndName>;
// Type error:
const thingy: FooObjectWithCompany = selectById("footable", "id", 1);
const otherThingy: BarObjectWithoutCompany = selectById("bartable", "id", 1, 2);
// No type error:
const thingyWorking: FooObjectWithCompany = selectById("footable", "id", 1, 1);
const otherThingyWorking: BarObjectWithoutCompany = selectById("bartable", "id", 1);
Now you get the error you expect because BarObjectWithoutCompany
must have no value for company_id
to be valid.
Upvotes: 1