user4301448
user4301448

Reputation: 320

Is there an idiomatic way in TypeScript to require a function argument if a type has a particular property?

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

Answers (1)

Alex Wayne
Alex Wayne

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.

Playground

Upvotes: 1

Related Questions