Ментальная модель функции TypesScript

Это система, которая позволит вам создавать согласованные и четко определенные функции TypeScript в вашей кодовой базе.

Функции TypeScript

Когда дело доходит до написания функций TypeScript, возможно, вы уже сталкивались с таким мышлением:

Это выглядит немного странно, я должен попробовать это по-другому? Возможно, здесь поможет перегрузка функций или, возможно, универсальные шаблоны. 🤔

И в итоге вы переоделись в одну из них, даже не зная, правильно ли это.

Я был там и, конечно, делал это бесчисленное количество раз, но что, если бы существовала система, которая могла бы помочь вам решить этот маленький нюанс?

Это именно то, что я представляю здесь — система, которая позволит вам принять хорошее и последовательное решение о том, как вы пишете функцию TypeScript.

Типы союзов

type Pet = {
  name: string;
};
type PetOwner = {
  name: string;
  pet: Pet;
};
function getPetName(petOrOwner: Pet | PetOwner) {
  if ("pet" in petOrOwner) {
    return petOrOwner.pet.name;
  }
  return petOrOwner.name;
}

Типы объединения TypeScript очень полезны, когда дело доходит до ввода чего-либо, что может иметь несколько определений типов.

В частности, для функций очень полезно указывать их параметры или возвращаемые типы. И, возможно, вы используете его чаще, чем предполагали, потому что каждый раз, когда вы определяете аргумент как optional, он на самом деле становится type | undefined, а это означает, что под ним находится тип объединения!

Когда использовать

Когда дело доходит до функций, это почти самая основная стратегия для ввода их параметров. Так что мой совет — придерживайтесь его, когда это возможно, но у него больше ограничений, чем у других стратегий, и он не работает для более сложных сценариев.

Используйте типы объединений, когда:

1- Вы знаете обо всех возможных членах каждого типа объединения в момент объявления функции;
2- тип возвращаемого значения не изменяется в зависимости о типах аргументов;

type Pet = {
  name: string;
};
type PetOwner = {
  ownerName: string;
  pet: Pet;
};
// Don't do this - you will return an ambiguous type ❌
function getObject(petOrOwner: Pet | PetOwner): Pet | PetOwner {
  return petOrOwner;
}

// You have no idea which will be the array type ❌
function getFirstElementArray(arr: (string | number)[]): any  {
  return arr[0];
}

Кроме того, при использовании типов объединения для типизации функций вам, скорее всего, не нужно указывать тип возвращаемого значения явно. Это просто потому, что возвращаемый тип вообще не должен меняться для такого способа ведения дел.

Перегрузка функций

type SingleNamePerson = {
  name: string;
};
type FullNamePerson = {
  name: string;
  surname: string;
};
// Overload signatures
function getPerson(name: string): SingleNamePerson;
function getPerson(name: string, surname: string): FullNamePerson;
// Implementation signature
function getPerson(
  name: string,
  surname?: string
): SingleNamePerson | FullNamePerson {
  if (name && surname) {
    return {
      name,
      surname,
    };
  }
  return {
    name,
  };
}

Перегрузка функций в TypeScript — отличный способ указать несколько сигнатур функций супердекларативным способом, при этом каждая из них имеет собственное определение типа, либо для аргументов, либо для возвращаемого типа.

Чтобы использовать этот способ ввода функции, все, что вам нужно сделать, это следующее:

1- Определите возможные подписи ваших функций (различные перегрузки)

// Overload signatures
function getPerson(name: string): SingleNamePerson;
function getPerson(name: string, surname: string): FullNamePerson;

2- Определите свои реализации функций — сигнатура этой функции должна быть объединена с другими ранее созданными функциями, чтобы она учитывала каждую перегрузку.

// name - required because it is defined in both overloads
// surname - optional, because it is only present in one of the overloads
// return type - SingleNamePerson | FullNamePerson because it can be either one of those
function getPerson(
  name: string,
  surname?: string
): SingleNamePerson | FullNamePerson {
  if (name && surname) {
    return {
      name,
      surname,
    };
  }
  return {
    name,
  };
}

Когда использовать

Перегрузка функций довольно хорошо работает с более сложными и динамичными сигнатурами, так как она позволяет определять несколько комбинаций простым для понимания способом.

Используйте перегрузку функций, когда:

1- Вы знаете обо всех возможных членах каждого типа объединения в момент объявления функции;
2- тип возвращаемого значения изменяется в зависимости от аргумента типы;
3- Тип возвращаемого значения не является прямым сопоставлением предоставленных параметров;

// Don't do this ❌ - the return type is a direct mapping of the provided argument (use a generic instead)
function getPerson(person: SingleNamePerson): SingleNamePerson;
function getPerson(person: FullNamePerson): FullNamePerson;
function getPerson(person: {
  name: string;
  surname?: string;
}): SingleNamePerson | FullNamePerson {
  const { name, surname } = person;
  if (name && surname) {
    return {
      name,
      surname,
    };
  }
  return {
    name,
  };
}

Общие функции

type SingleNamePerson = {
  name: string;
};
type FullNamePerson = {
  name: string;
  surname: string;
};
const singleNamePerson: SingleNamePerson = {
  name: "Bob",
};
const fullNamePerson: FullNamePerson = {
  name: "Bob",
  surname: "Smith",
};
function getPerson<PersonT>(arg: PersonT): PersonT {
  return arg;
}
getPerson(singleNamePerson); // Return type => `SingleNamePerson`
getPerson(fullNamePerson); // Return type => `FullNamePerson`

Универсальные функции TypeScript, вероятно, являются самым универсальным способом создания функции, которая так или иначе является динамической.

Это позволяет вам получить полную поддержку типов для каждой возможной абстракции для функции, которая имеет дело с очень разными сценариями (и где вы не полностью осведомлены о том, что может быть передано в качестве аргумента заранее).

Но все эти возможности и универсальность имеют свою цену. Поскольку дженерики подходят везде, разработчики склонны злоупотреблять ими, и, поверьте мне, никто не захочет редактировать функцию, окруженную слишком сложными универсальными типами, особенно если она написана другим инженером.

Когда использовать

Универсальные функции — это единственное решение общей проблемы между типами объединения и перегрузкой функций — возможность добавления поддержки типов, когда мы заранее не знаем обо всех возможных типах.

Кроме того, обобщения также являются отличным вариантом, когда возвращаемый тип функции связан с типами параметров (даже если вы знаете о них заранее).

// Defining the return type based on the argument's type
function getFirstElement<T>(array: T[]): T {
    return array[0];
}
const a = getFirstElement([1, 2, 3]); // return type => number
const b = getFirstElement(["Hello", "World"]); // return type => string
const c = getFirstElement([true, false]); // return type => boolean

Используйте общие функции, когда:
1– вы НЕ знаете заранее о типах аргументов;
2– тип возвращаемого значения является прямым сопоставление (или близкое к нему) предоставленных параметров;

type Dog = {
  name: string;
  toy: string;
};
type Cat = {
  name: string;
  furrballs: number;
};
type Turtle = {
  name: string;
  isMainlyAquatic: boolean;
};
// This can get tricky pretty quickly ❌
type GetAnimalReturnType<AnimalT> = AnimalT extends Dog
  ? "canine"
  : AnimalT extends Cat
  ? "feline"
  : "turtle";
// We need to use type assertions - not ideal ❓
function getAnimalType<AnimalT>(animal: AnimalT): GetAnimalReturnType<AnimalT> {
  if ("toy" in animal) {
    return "canine" as GetAnimalReturnType<AnimalT>;
  }
  if ("furrballs" in animal) {
    return "feline" as GetAnimalReturnType<AnimalT>;
  }
  return "turtle" as GetAnimalReturnType<AnimalT>;
}

Дженерики против перегрузки функций

Когда дело доходит до выбора между перегрузкой функций и универсальными шаблонами, грань размыта, и решение не совсем ясно. Как вы можете видеть на изображении выше, использование этих двух функций пересекается, когда дело доходит до ввода функции, которая имеет известные типы аргументов и чей тип возвращаемого значения зависит от тех же самых аргументов.

Что выбрать

Ответ будет зависеть от предпочтений разработчика, но я считаю, что для простоты вам следует выбирать generics вместо function overloading только тогда, когда применяется это правило:

Код, который необходимо написать для определения типа возвращаемого значения, не содержит более 1 уровня глубины extends выражений.

Это правило, которое я использую для себя, чтобы код оставался согласованным и легким для чтения другими.

Заключение

Существует несколько методов написания динамических функций на TypeScript, и, применяя эту «ментальную модель», вы сможете определять эти функции более последовательным и чистым способом, а также использовать каждый метод по назначению. 👇

  • Объединения => Используйте, когда тип возвращаемого значения не меняется;
  • Function Overloading => Используйте, когда вы знаете типы аргументов и тип возвращаемого значения меняется в зависимости от типов аргументов;
  • Общие => Используйте, когда вы не знаете типы аргументов или тип возвращаемого значения является прямым сопоставлением типов аргументов.

Подпишитесь на меня в Твиттере, если вы хотите прочитать о лучших практиках TypeScript или просто о веб-разработке в целом!