Привет, Хабр!

Частенько сталкиваются с проблемой поддержания типовой безопасности в React-проекте. Код разрастается, и управление типами становится всё сложнее. Ошибки, вызванные неправильной типизацией, приводят к крашам и длительным отладкам. Тогда приходит время внедрения TypeScript!

В статье рассмотрим как TypeScript может помочь решить проблемы с типизацией и сделать React-код идеально типизированным.

Строгая типизация и Type Inference в TypeScript

Строгий режим TypeScript strict — это конфигурация, которая включает ряд некоторых строгих проверок типов.

Чтобы включить строгий режим в проекте, необходимо изменить файл конфигурации TypeScript tsconfig.json:

{
  "compilerOptions": {
    "strict": true
  }
}

Это автоматом включает несколько поднастроек:

  • noImplicitAny: отключает неявное присвоение типа any. Все переменные должны иметь явный тип.

  • strictNullChecks: обспечивает строгую проверку null и undefined. Это предотвращает использование переменных, которые могут быть null или undefined, без соответствующей проверки.

  • strictFunctionTypes: включает строгие проверки типов для функций.

  • strictPropertyInitialization: проверяет, что все обязательные свойства инициализируются в конструкторе класса.

  • noImplicitThis: отлючает неявное присвоение типа any для this в функциях.

  • alwaysStrict: включает строгий режим JavaScript во всех файлах.

Пример строгого режима:

function add(a: number, b: number): number {
  return a + b;
}

let result = add(2, 3); // OK
let result2 = add('2', 3); // ошибка компиляции: тип 'string' не может быть присвоен параметру типа 'number'

Вывод типов (Type Inference) позволяет автоматически определяет типы переменных и выражений на основе их значения или контекста использования.

Когда мы объявляем переменную или функцию без явного указания типа, TypeScript пытается вывести тип автоматом на основе присвоенного значен:

let x = 3; // TypeScript выводит тип 'number'
let y = 'privet'; // TypeScript выводит тип 'string'
let z = { name: 'Artem', age: 30 }; // TypeScript выводит тип { name: string; age: number }

TypeScript автоматически определяет тип переменных x, y и z на основе их значений.

Иногда вывод типов может быть недостаточно точным или полезным, например тут:

let items = ['apple', 'banana', 42]; // Тип выводится как (string | number)[]

Мссив items имеет тип (string | number)[], что может не соответствовать ожидаемому поведению. В таких случаях лучше явно указать тип.

Переходим к следующему пункту - правильной типизации Props и State в React с TypeScript

Правильная типизация Props и State в React с TypeScript

Правильное определение типов для Props и State помогает создать более структурированный код.

В TypeScript есть два основных способа определения типов: интерфейсы и типы. Хотя оба подхода имеют схожие возможности, есть некоторые различия:

Интерфейсы:

  • Обычно их используют для определения структур данных и контрактов для публичных API.

  • Поддерживают декларативное слияние.

  • Лучше подходят для объектов с множеством свойств.

Типы:

  • Используются для определения алиасов типов, особенно для объединений и пересечений типов.

  • Более гибкие.

  • Лучше подходят для простых объектов, состояний и внутренних компонентов.

Пример интерфейсов для Props:

import React from 'react';

interface ButtonProps {
  label: string;
  onClick: () => void;
}

const Button: React.FC<ButtonProps> = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

export default Button;

Пример типов для State:

import React, { useState } from 'react';

type CounterState = {
  count: number;
};

const Counter: React.FC = () => {
  const [state, setState] = useState<CounterState>({ count: 0 });

  const increment = () => {
    setState({ count: state.count + 1 });
  };

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
};

export default Counter;

Для указания обязательных свойств можно использовать просто имя свойства, а для необязательных добавляйте знак ?:

interface UserProps {
  name: string; // обязательное свойство
  age?: number; // необязательное свойство
}

Для типизации сложных объектов и массивов можно юзать вложенные интерфейсы или типы:

interface Address {
  street: string;
  city: string;
}

interface UserProps {
  name: string;
  age?: number;
  address: Address; // вложенный объект
  hobbies: string[]; // массив строк
}

Union типы позволяют объединять несколько типов, а intersection типы — пересекать их:

type Status = 'success' | 'error' | 'loading';

interface Response {
  data: string;
}

type ApiResponse = Response & { status: Status };

Переходим к следующему поинту - пользовательские хуки.

Пользовательские хуки

Пользовательские хуки в React позволяют инкапсулировать и переиспользовать логику состояния и побочных эффектов.

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

Пример создания простого пользовательского хука для управления состоянием счетчика:

import { useState } from 'react';

/**
 * Пользовательский хук useCounter.
 * @param initialValue начальное значение счетчика.
 * @returns Текущее значение счетчика и функции для его увеличения и сброса.
 */
function useCounter(initialValue: number) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(count + 1);
  const reset = () => setCount(initialValue);

  return { count, increment, reset };
}

export default useCounter;

Этот хук можно использовать в любом компоненте:

import React from 'react';
import useCounter from './useCounter';

const CounterComponent: React.FC = () => {
  const { count, increment, reset } = useCounter(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
};

export default CounterComponent;

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

Пример создания пользовательского хука для управления состоянием формы:

import { useState } from 'react';

type ChangeEvent<T> = React.ChangeEvent<T>;

/**
 * Пользовательский хук useForm.
 * @param initialValues Начальные значения формы.
 * @returns Текущие значения формы, функция для обработки изменений и функция для сброса формы.
 */
function useForm<T>(initialValues: T) {
  const [values, setValues] = useState<T>(initialValues);

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target;
    setValues({
      ...values,
      [name]: value
    });
  };

  const resetForm = () => setValues(initialValues);

  return { values, handleChange, resetForm };
}

export default useForm;

Этот хук также можно использовать для управления состоянием формы в любом компоненте:

import React from 'react';
import useForm from './useForm';

interface FormValues {
  username: string;
  email: string;
}

const FormComponent: React.FC = () => {
  const { values, handleChange, resetForm } = useForm<FormValues>({
    username: '',
    email: ''
  });

  const handleSubmit = (event: React.FormEvent) => {
    event.preventDefault();
    console.log(values);
    resetForm();
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Username:
        <input
          type="text"
          name="username"
          value={values.username}
          onChange={handleChange}
        />
      </label>
      <label>
        Email:
        <input
          type="email"
          name="email"
          value={values.email}
          onChange={handleChange}
        />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
};

export default FormComponent;

Пользовательские хуки могут быть использованы для реализации сложных логик. И вот пример создания пользовательского хука для получения данных с API:

import { useState, useEffect } from 'react';

interface ApiResponse<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

/**
 * Пользовательский хук useFetch.
 * @param url URL для запроса.
 * @returns Состояние запроса, данные, ошибка и статус загрузки.
 */
function useFetch<T>(url: string): ApiResponse<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const result = await response.json();
        setData(result);
      } catch (error) {
        setError(error.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading, error };
}

export default useFetch;

Этот хук можно использовать для получения данных в компоненте:

import React from 'react';
import useFetch from './useFetch';

interface User {
  id: number;
  name: string;
}

const UserList: React.FC = () => {
  const { data, loading, error } = useFetch<User[]>('https://jsonplaceholder.typicode.com/users');

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <ul>
      {data?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

export default UserList;

Переходим к следующей важной теме - универсальные компоненты с дженериками.

Универсальные компоненты с Generic Components

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

Пример создания простого компонента списка, который может принимать любой тип данных:

import React from 'react';

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
}

function List<T>({ items, renderItem }: ListProps<T>): React.ReactElement {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

export default List;

Компонент List может быть использован с любыми типами данных:

import React from 'react';
import List from './List';

interface User {
  id: number;
  name: string;
}

const users: User[] = [
  { id: 1, name: 'Kolya' },
  { id: 2, name: 'Vanya' },
];

const App: React.FC = () => {
  return (
    <div>
      <h1>User List</h1>
      <List items={users} renderItem={(user) => <span>{user.name}</span>} />
    </div>
  );
};

export default App;

Универсальные таблицы — это еще один пример компонентов, которые могут выиграть от использования Generics. Пример:

import React from 'react';

interface TableProps<T> {
  columns: (keyof T)[];
  data: T[];
  renderCell: (item: T, column: keyof T) => React.ReactNode;
}

function Table<T>({ columns, data, renderCell }: TableProps<T>): React.ReactElement {
  return (
    <table>
      <thead>
        <tr>
          {columns.map((column) => (
            <th key={String(column)}>{String(column)}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((item, rowIndex) => (
          <tr key={rowIndex}>
            {columns.map((column) => (
              <td key={String(column)}>{renderCell(item, column)}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

export default Table;

Этот компонент можно использовать для отображения данных любого типа:

import React from 'react';
import Table from './Table';

interface Product {
  id: number;
  name: string;
  price: number;
}

const products: Product[] = [
  { id: 1, name: 'Laptop', price: 1000 },
  { id: 2, name: 'Phone', price: 500 },
];

const App: React.FC = () => {
  return (
    <div>
      <h1>Product Table</h1>
      <Table
        columns={['id', 'name', 'price']}
        data={products}
        renderCell={(item, column) => item[column]}
      />
    </div>
  );
};

export default App;

Универсальные формы, которые могут принимать различные типы данных для различных полей, также могут быть реализованы с помощью Generics:

import React, { useState } from 'react';

interface FormProps<T> {
  initialValues: T;
  renderForm: (values: T, handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void) => React.ReactNode;
  onSubmit: (values: T) => void;
}

function Form<T>({ initialValues, renderForm, onSubmit }: FormProps<T>): React.ReactElement {
  const [values, setValues] = useState<T>(initialValues);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setValues({
      ...values,
      [name]: value
    });
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSubmit(values);
  };

  return (
    <form onSubmit={handleSubmit}>
      {renderForm(values, handleChange)}
      <button type="submit">Submit</button>
    </form>
  );
}

export default Form;

Использование этого компонента для создания формы:

import React from 'react';
import Form from './Form';

interface UserProfile {
  username: string;
  email: string;
}

const App: React.FC = () => {
  const initialValues: UserProfile = { username: '', email: '' };

  const handleSubmit = (values: UserProfile) => {
    console.log(values);
  };

  return (
    <div>
      <h1>User Profile Form</h1>
      <Form
        initialValues={initialValues}
        renderForm={(values, handleChange) => (
          <>
            <label>
              Username:
              <input type="text" name="username" value={values.username} onChange={handleChange} />
            </label>
            <label>
              Email:
              <input type="email" name="email" value={values.email} onChange={handleChange} />
            </label>
          </>
        )}
        onSubmit={handleSubmit}
      />
    </div>
  );
};

export default App;

На этом моменте хотелось уже закончить статью, но есть еще один важный поинт - внешние библиотеки.

Интеграция и типизация внешних библиотек

Большинство популярных JS-библиотек имеют типы, которые можно установить через npm или yarn. Эти типы находятся в специальном пространстве имен @types.

Установка типов через npm:

npm install @types/library-name

Установка типов через yarn:

yarn add @types/library-name

Пример установки типов для библиотеки lodash:

npm install lodash @types/lodash

После установки типов можно использовать библиотеку с полной типовой поддержкой. Пример с использованием lodash:

import _ from 'lodash';

const numbers: number[] = [1, 2, 3, 4, 5];
const doubled = _.map(numbers, num => num * 2);

console.log(doubled); // [2, 4, 6, 8, 10]

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

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

Предположим, есть библиотека example-library, у которой нет готовых типов. Создадим собственные декларации типов для этой библиотеки.

  1. Создаем файл с типами, например example-library.d.ts.

  2. Определяем типы для используемых функций и объектов библиотеки.

Пример:

// example-library.d.ts
declare module 'example-library' {
  export function exampleFunction(param: string): number;
  export const exampleConstant: string;
}

После создания этого файла можно использовать библиотеку с типовой поддержкой:

import { exampleFunction, exampleConstant } from 'example-library';

const result: number = exampleFunction('test');
console.log(result); 

console.log(exampleConstant);

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

{
  "compilerOptions": {
    "skipLibCheck": true
  }
}

Финальные слова

TypeScript в React-проектах — это не просто рекомендация, а необходимость для тех, кто хочет создать надежное, масштабируемое, а самое главное - легкое в сопровождении приложение.

Больше про языки программирования эксперты OTUS рассказывают в рамках практических онлайн-курсов. С полным каталогом курсов можно ознакомиться по ссылке.

Комментарии (4)


  1. Keeper10
    26.07.2024 07:43
    +1

    сделать React-код идеально типизированным.

    Зачем его делать идеально типизированным?


  1. teilarerJs
    26.07.2024 07:43
    +3

    Вы написали хотя бы одну строчку на typescript в своей жизни, ну если честно?

    Пример строгого режима:

    function add(a: number, b: number): number {  return a + b;}let result = add(2, 3); // OKlet result2 = add('2', 3); // ошибка компиляции: тип 'string' не может быть присвоен параметру типа 'number'

    Это не пример строгого режима, даже если выключить вообще все флаги строгости, ts подсветит ошибку.


    Иногда вывод типов может быть недостаточно точным или полезным, например тут:

    Массив items имеет тип (string | number)[], что может не соответствовать ожидаемому поведению. В таких случаях лучше явно указать тип.

    let items = ['apple', 'banana', 42]; // Тип выводится как (string | number)[]

    В каком месте здесь вывод типов недостаточно точный/полезный, и не соответствует ожидаемому поведению?

    Интерфейсы:

    • Лучше подходят для объектов с множеством свойств.

    Чушь. О различиях доходчиво написано в документации.
    https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#differences-between-type-aliases-and-interfaces

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


  1. dopusteam
    26.07.2024 07:43

    Мссив items имеет тип (string | number)[], что может не соответствовать ожидаемому поведению. В таких случаях лучше явно указать тип.

    А какой тип вы ожидаете?


    1. Alexandroppolus
      26.07.2024 07:43

      Рискну предположить, что [string, string, number]