TypeScript + React: путь к идеально типизированному коду
- суббота, 27 июля 2024 г. в 00:00:05
Привет, Хабр!
Частенько сталкиваются с проблемой поддержания типовой безопасности в React-проекте. Код разрастается, и управление типами становится всё сложнее. Ошибки, вызванные неправильной типизацией, приводят к крашам и длительным отладкам. Тогда приходит время внедрения TypeScript!
В статье рассмотрим как TypeScript может помочь решить проблемы с типизацией и сделать React-код идеально типизированным.
Строгий режим 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 помогает создать более структурированный код.
В 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;
Переходим к следующей важной теме - универсальные компоненты с дженериками.
С универсальными компонентами можно создавать списки, таблицы или формы, где структура данных может варьироваться.
Пример создания простого компонента списка, который может принимать любой тип данных:
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
, у которой нет готовых типов. Создадим собственные декларации типов для этой библиотеки.
Создаем файл с типами, например example-library.d.ts
.
Определяем типы для используемых функций и объектов библиотеки.
Пример:
// 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 рассказывают в рамках практических онлайн-курсов. С полным каталогом курсов можно ознакомиться по ссылке.