Typescript: unsound behavior или поблажки надежности

в 15:18, , рубрики: type safety, TypeScript

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

Мы не будем говорить о багах, в TS их достаточно
1,500 open bugs and 6,000 closed (‘is:issue is:open label:Bug’)

Все примеры будет рассмотрены при:

  • TS strict mode включен (написал статью пока разбирался)
  • Без явных «any»: «as any», «Objects», «Function», {[key: string]: unknown}
  • Без неявных «any»: (noImplicitAny): не типизированные импорты (pure JS files), неправильный вывод типов
  • Без ложных догадок о типах: ответ от сервера, типизация сторонних библиотек

Содержание:

  • Введение
  • Nominal types, custom types — когда вещи кажутся одинаковым, но такие разные
  • Type variance, exact types — об отношении между типами
  • Refinement invalidation — поговорим о доверии
  • Exceptions — стоит ли признаваться, когда накосячил?
  • Unsafe operations — уверенность не всегда идет на пользу
  • Бонусные случаи — проверка типов на этапе PR ревью
  • Заключение

Введение

Сложно ли написать функцию для сложения двух чисел на JS? Возьмем наивную реализацию

function sum(a, b) {
	return a + b;
}

Проверим нашу реализацию `sum(2, 2) === 4`, все кажется работает? Не совсем, когда описываем функцию, мы должны задуматься о всевозможных входных значениях, также и о том, что может вернуть функция

1.1 + 2.7   // 3.8000000000000003
NaN + 2     // NaN
99999999999999992 + 99999999999999992 // 200000000000000000
2n + 2      // Uncaught TypeError: 
            // Cannot mix BigInt and other types, use explicit conversions.
{} + true   // 1
2 + '2'     // '22'

Soundness — это возможность анализатора доказать отсутствие ошибок во время работы программы. Если программа была принята анализатором, то она гарантирована будет безопасна (safe).

Safe program — программа, которая может работать вечно без ошибок. Т.е. программа не будет падать или бросать ошибки.

Correct program — программа, которая делает то, что должна и не делает того, что не должна. Корректность зависит от исполнения бизнес логики.

Типы могут доказать, что программа в целом безопасна, а тесты — что программа безопасна и корректна только в рамках тестовых данных (100% покрытие, отсутствие «мутантов» от stryker, прохождение property based test и прочее не может что-то доказывать, а лиши снижает риски). Ходят легенды, что theorem provers могут доказывать и корректность программы.

Важно разобраться с философией TS, понять что пытается решить инструмент и, что важно, что решить не пытается.

A note on Soundness
TS пропускает некоторые операции, в которых не уверен на этапе компиляции. Места с unsound behavior были бережно продуманы.

Design goals
Не цель для TS — Сделать систему типов с гарантией безопасности, вместо этого сконцентрироваться на балансе между безопасностью и продуктивностью

Структура примеров:
Проблема — не безопасное поведение, список может быть не полон, это то, что я нашел в статьях, докладах, в TS git issues.
Предложение — это TS issue открытые 3-4 года назад, с кучей комментариев и интересными пояснениями авторов
Совет — ИМХО автора, то, что автор считает хорошими практиками

Structural vs Nominal typing

Structural vs Nominal typing 1. Проблема

Структурная типизация — когда при сравнение типов не учитывается их имена или где они были объявлены, а типы сравниваются по «структуре».

Мы хотим отправить письмо `sendEmail` по корректному адресу `ValidatedEmail`, есть функция проверки адреса `validateEmail` которая возвращает корректный адрес `ValidatedEmail`. К сожалению TS позволяет передать любую строку в `sendEmail`, т.к. `ValidatedEmail` для TS не отличается от `string`

type ValidatedEmail = string;
declare function validateEmail(email: string): ValidatedEmail;

declare function sendEmail(mail: ValidatedEmail): void;
sendEmail(validateEmail("asdf@gmail.com"));

// Should be error!
sendEmail("asdf@gmail.com");

Structural vs Nominal typing 1. Предложение

github.com/microsoft/TypeScript/issues/202
Ввести ключевое слово `nominal`, чтобы типы проверялись Номинально. Теперь мы сможем запретить передавать просто `string` где ожидается `ValidatedEmail`

nominal type ValidatedEmail = string;
declare function validateEmail(email: string): ValidatedEmail;

declare function sendEmail(mail: ValidatedEmail): void;
sendEmail(validateEmail('asdf@gmail.com'));

// Error!
sendEmail('asdf@gmail.com');

Structural vs Nominal typing 1. Совет

Мы можем создать `Opaque` тип, который будет принимать некий `T` и придавать ему уникальности объединяя с типом, созданным из переданного `K`. `K` может быть как уникальным символом (`unique symbol`), так и строкой (тогда нужно будет следить, чтобы эти строки были уникальны).

type Opaque<K extends symbol | string, T> 
	= T & { [X in K]: never };

declare const validatedEmailK: unique symbol;
type ValidatedEmail = Opaque<typeof validatedEmailK, string>;
// type ValidatedEmail = Opaque<'ValidatedEmail', string>;

declare function validateEmail(email: string): ValidatedEmail;

declare function sendEmail(mail: ValidatedEmail): void;
sendEmail(validateEmail('asdf@gmail.com'));

// Argument of type '"asdf@gmail.com"' is not assignable
//  to parameter of type 'Opaque<unique symbol, string>'.
sendEmail('asdf@gmail.com');

Structural vs Nominal typing 2. Проблема

У нас есть класс Доллара и Евро, у каждого из классов есть метод `add` для сложения Доллара с Долларом и Евро с Евро. Для TS структурно эти классы равны и мы можем сложить Доллар с Евро.

export class Dollar {
  value: number;

  constructor(value: number) {
    this.value = value;
  }

  add(dollar: Dollar): Dollar {
    return new Dollar(dollar.value + this.value);
  }
}

class Euro {
  value: number;

  constructor(value: number) {
    this.value = value;
  }

  add(euro: Euro): Euro {
    return new Euro(euro.value + this.value);
  }
}

const dollars100 = new Dollar(100);
const euro100 = new Euro(100);

// Correct
dollars100.add(dollars100);
euro100.add(euro100);

// Should be error!
dollars100.add(euro100);

Structural vs Nominal typing 2. Предложение

github.com/microsoft/TypeScript/issues/202
Предложение все тоже, с `nominal`, но т.к. классы могут магически стать Номинальными (об этом чуть позже), то рассматриваются возможности сделать такое превращение более явным образом.

Structural vs Nominal typing 1. Совет

Если у класса есть приватное поле (нативное с `#` или от TS c `private`), то класс магически становится Номинальным, имя и значение может быть любым. Используется `!` (definite assignment assertion) чтобы TS не ругался на не проинициализированное поле (strictNullChecks, strictPropertyInitialization флаги включены).

class Dollar {
  // #desc!: never;
  private desc!: never;
  value: number;

  constructor(value: number) {
    this.value = value;
  }

  add(dollar: Dollar) {
    return new Dollar(dollar.value + this.value);
  }
}

class Euro {
  // #desc!: never;
  private desc!: never;
  value: number;

  constructor(value: number) {
    this.value = value;
  }

  add(euro: Euro) {
    return new Euro(euro.value + this.value);
  }
}

const dollars100 = new Dollar(100);
const euro100 = new Euro(100);

// Correct
dollars100.add(dollars100);
euro100.add(euro100);

// Error: Argument of type 'Euro' is not assignable to parameter of type 'Dollar
dollars100.add(euro100);

Type variance 1. Проблема

Вариантность в программировании, если кратко — это возможность передавать Supertype/Subtype туда, гда Type ожидается. Например, есть иерархия Shape -> Circle -> Rectangle то можно ли передать или вернуть Shape/Rectangle, если ожидается Circle?

Вариантность в программировании habr, SO.

Мы можем передать тип с полем в котором лежит число, в функцию, что ожидает поле как строку или число, а в теле мутирует переданный объект, изменяя поле на строку. Т.е. `{ status: number } as { status: number | string } as { status: string }` вот такой фокус как превратить число в строку, вызвав удивление ошибку.

function changeStatus(arg: { status: number | string }) {
  arg.status = "NotFound";
}

const error: { status: number } = { status: 404 };
changeStatus(error);

// Error: toFixed is not a function
console.log(error.status.toFixed());

Type variance 1. Предложение

github.com/Microsoft/TypeScript/issues/10717
Предлагается ввести `in/out` чтобы явно ограничить ковариантность/контравариантность для дженериков.

function changeStatus<
  out T extends {
    status: number | string;
  }
>(arg: T) {
  arg.status = "NotFound";
}

const error: { status: number } = { status: 404 };
// Error!
changeStatus(error);

console.log(error.status.toFixed());

Type variance 1. Совет

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

function changeStatus(arg: Readonly<{ status: number | string }>) {
  // Error: Cannot assign, status is not writable
  arg.status = "NotFound";
}

const error: Readonly<{ status: number }> = { status: 404 };
changeStatus(error);

console.log(error.status.toFixed());

Type variance 1. Бонус

Readonly is assignable to mutable
github.com/Microsoft/TypeScript/issues/13347
github.com/microsoft/TypeScript/pull/6532#issuecomment-174356151

Но, даже, если мы создали Readonly тип, то TS не запретит передать в функцию, где ожидается не Readonly `Readonly<{ readonly status: number }> as { status: number | string } as { status: string }`

function changeStatus(arg: { status: number | string }) {
  arg.status = "NotFound";
}

const error: Readonly<{ readonly status: number }> 
  = { status: 404 };
changeStatus(error);

// Error: toFixed is not a function
console.log(error.status.toFixed());

Type variance 2. Проблема

Объекты могут содержать дополнительные поля, которых нету у соответствующих им типах: `{ message: string; status: string } as { message: string }`. Из-за чего некоторые операции могут быть не безопасны

const error: { message: string; status: string } = {
  message: "No data",
  status: "NotFound"
};

function updateError(arg: { message: string }) {
  const defaultError = { message: "Not found", status: 404 };
  const newError: { message: string; status: number }
    = { ...defaultError, ...arg };
  
  // Error: toFixed is not a function
  console.log(newError.status.toFixed());
}

updateError(error);

TS подумал, что в результате слияния `{ ...{ message: string, status: number }, ...{ message: string }}` status будет число.

В реальности `{...{ message: «Not found», status: 404 }, ...{ message: «No data», status: «NotFound»},}` status — стринга.

Type variance 2. Предложение

Введение типа `Exact` или схожего по назначению синтаксиса, чтобы сказать, что тип не может содержать дополнительных полей.

const error: Exact<{ message: string; }> = {
  message: "No data",
};

function updateError(arg: Exact<{ message: string }>) {
  const defaultError = {  message: "Not found", status: 404, };
  // Can spread only Exact type!
  const newError = { ...defaultError, ...arg };
  console.log(newError.status.toFixed());
}

updateError(error);

Type variance 2. Совет

Мержить объекты, явно перечисляя поля или отфильтровывать не известные поля.

const error: { message: string; status: string } = {
  message: "No data",
  status: "NotFound"
};

function updateError(arg: { message: string }) {
  const defaultError = { message: "Not found", status: 404 };
  // Merge explicitly or filter unknown fields
  const newError = { ...defaultError, message: arg.message };
  console.log(newError.status.toFixed());
}

updateError(error);

Refinement invalidation. Проблема

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

export function logAge(name: string, age: number) {
  // 2nd call -  Error: toFixed is not a function
  console.log(`${name} will lose ${age.toFixed()}`);
  person.age = "PLACEHOLDER";
}

const person: { name: string; age: number | string } = {
  name: "Person",
  age: 42
};

if (typeof person.age === "number") {
  logAge(person.name, person.age);
  // refinement should be invalidated
  logAge(person.name, person.age);
}

Refinement invalidation. Предложение

github.com/microsoft/TypeScript/issues/7770#issuecomment-334919251
Добавить модификатор `pure` для функций, это как минимум позволит доверять таким функциям

Refinement invalidation. Совет

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

Бонус

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

Exceptions. Проблема

TS никак не помогает работать с Exceptions, по сигнатуре функции ничего не ясно.

import { JokeError } from "../helpers";

function getJoke(isFunny: boolean): string {
  if (isFunny) {
    throw new JokeError("No funny joke");
  }
  return "Duh";
}

const joke: string = getJoke(true);
console.log(joke);

Exceptions. Предложение

github.com/microsoft/TypeScript/issues/13219
Предлагается ввести синтаксис позволяющий явно описывать Exceptions в сигнатуре функций

import { JokeError } from '../helpers';

function getJoke(isFunny: boolean): string | throws JokeError {
  /*...*/}

function getJokeSafe(isFunny: boolean): string {
  try {
    return getJoke(isFunny);
  } catch (error) {
    if (error instanceof JokeError) {
      return "";
    } else {
      // Should infer error correctly, should cast to never
      return error as never;
    }
  }
}

console.log(getJokeSafe(true));

Exceptions. Бонус

github.com/microsoft/TypeScript/issues/6283
Почему-то в TS определение типа для Promise игнорирует тип ошибки

const promise1: Promise<number> = Promise.resolve(42);

const promise: Promise<never> = Promise.reject(new TypeError());

// typescript/lib
interface PromiseConstructor {
  new <T>(
    executor: (
      resolve: (value?: T | PromiseLike<T>) => void,
      reject: (reason?: any) => void
    ) => void
  ): Promise<T>;
}

Exceptions. Совет

Возьмем некий контейнер Either, как Promise, только с лучшей типизацией. (Пример реализации Either)

import { Either, exhaustiveCheck, JokeError } from "../helpers";

function getJoke(isFunny: boolean): Either<JokeError, string> {
  if (isFunny) {
    return Either.left(new JokeError("No funny joke"));
  }
  return Either.right("Duh");
}

getJoke(true)
  // (parameter) error: JokeError
  .mapLeft(error => {
    if (error instanceof JokeError) {
      console.log("JokeError");
    } else {
      exhaustiveCheck(error);
    }
  })
  // (parameter) joke: string
  .mapRight(joke => console.log(joke));

Unsafe operations. Проблема

Если у нас есть кортеж фиксированного размера, то TS может гарантировать, что по запрошенному индексу что-то есть. Для массива подобное работать не будет и TS будет нам доверять

// ReadOnly fixed size tuple
export const constNumbers: readonly [1, 2, 3] 
  = [1, 2, 3] as const;

// Error: Object is possibly 'undefined'.
console.log(constNumbers[100].toFixed());

const dynamicNumbers: number[] = [1, 2, 3];
console.log(dynamicNumbers[100].toFixed());

Unsafe operations. Предложение

github.com/microsoft/TypeScript/issues/13778
Предлагается добавить `undefined` к возвращаемому типу `T` для доступа по индексу к массиву. Но в таком случае при доступе по любому индексу придется использовать `?` или делать явные проверки.

// interface Array<T> {
//   [n: number]: T | undefined;
// }

const dynamicNumbers: number[] = [1, 2, 3];
// Error: Object is possibly 'undefined'.
console.log(dynamicNumbers[100].toFixed());

// Optional chaining `?`
console.log(dynamicNumbers[100]?.toFixed());

// type refinement
if (typeof dynamicNumbers[100] === 'number') {
  console.log(dynamicNumbers[100].toFixed());
}

Unsafe operations. Совет

Чтобы не плодить сущностей сверх надобности возьмем известный ранее контейнер `Either` и напишем безопасную функцию по работе с индексом, которая будет возвращать `Either<null, T>`.

import { Either } from "../helpers";

function safeIndex<T>(
  array: T[],
  index: number,
): Either<null, T> {
  if (index in array) {
    return Either.right(array[index]);
  }
  return Either.left(null);
}

const dynamicNumbers: number[] = [1, 2, 3];

safeIndex(dynamicNumbers, 100)
  .mapLeft(() => console.log("Nothing"))
  .mapRight(el => el + 2)
  .mapRight(el => console.log(el.toFixed()));

Бонус. Перезагрузка функций

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

P.S. посмотрите на альтернативу через generic и conditional types:

function add(a: string, b: string): string;
function add(a: number, b: number): number;
function add(a: string | number,
             b: string | number,
): string | number {
  return `${a} + ${b}`;
}

const sum: number = add(2, 2);
// Error: toFixed is not a function
sum.toFixed();

Бонус. Type guard

TS доверяет программисту, что `isSuperUser` правильно определяет кто `SuperUser` и если будет добавлен `Vasya`, никаких подсказок не будет.

P.S. стоит думать о том, как мы будем различать типы уже на этапе их объединения — tagged union

type SimpleUser = { name: string };
type SuperUser = { 
  name: string; 
  isAdmin: true; 
  permissions: string[] 
};
type Vasya = { name: string; isAdmin: true; isGod: true };
type User = SimpleUser | SuperUser | Vasya;

function isSuperUser(user: User): user is SuperUser {
  return "isAdmin" in user && user.isAdmin;
}

function doSomethings(user: User) {
  // Error: Cannot read property 'join' of undefined
  if (isSuperUser(user)) {
    console.log(user.permissions.join(","));
  }
}

Выводы по советам

— Nominal types: Opaque type, private fields
— Type variance: Exact types, DeepReadonly type
— Exceptions: Either monad
— Refinement invalidation: Pure functions
— Unsafe operations (index access): Either/Maybe monads

Иммутабельные данные, чистые функции, монады… Поздравляю, мы доказали, что ФП — это круто!

Выводы

  • TS хочет найти баланс между корректностью и продуктивностью
  • Тесты могут доказать безопасность и корректность только для тестовых данных.
  • Типы могут доказать общую безопасность программы.
  • Мутировать — плохо, понятненько?

Как ДЗ рекомендовал бы поиграться с Flow исправив простую ошибку:

// https://flow.org/try/
declare function log(arg: { name: string, surname?: string }): void;
const person: { name: string } =  { name: 'Negasi' };
// Error
log(person);

Код примеров, решение и разбор задачи, полезные ссылки в репозитории.

Автор: buggy375

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js