Typescript: unsound behavior or indulgences of reliability

The goal is to show where TS gives the illusion of security, allowing you to get errors while the program is running.

We will not talk about bugs, in TS there are enough
1,500 open bugs and 6,000 closed ('is: issue is: open label: Bug').

All examples will be considered with:

  • TS strict mode is on (wrote an article while understanding)
  • Without explicit "any": "as any", "Objects", "Function", {[key: string]: unknown}
  • Without implicit "any": (noImplicitAny): untyped imports (pure JS files), incorrect type inference
  • Without false guesses about types: response from the server, typing of third-party libraries

Content:

  • Introduction
  • Nominal types, custom types - when things seem the same, but so different
  • Type variance, exact types - about the relationship between types
  • Refinement invalidation - talk about trust
  • Exceptions - is it worth it to admit when messed up?
  • Unsafe operations - confidence is not always good
  • Bonus Cases - Type Checking at the PR Review Stage
  • Conclusion

Introduction


Is it difficult to write a function to add two numbers in JS? Take a naive implementation

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

Let's check our implementation of `sum (2, 2) === 4`, does everything seem to work? Not really, when we describe a function, we should think about all kinds of input values, as well as what the function can return

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 is the analyzer's ability to prove that there are no errors while the program is running. If the program was accepted by the analyzer, then it is guaranteed to be safe.

Safe program is a program that can work forever without errors. Those. the program will not crash or throw errors.

Correct program - a program that does what it should and does not do what it should not. Correctness depends on the execution of business logic.

Types can prove that the program as a whole is safe, and tests that the program is safe and correct only within the test data (100% coverage, the absence of β€œmutants” from stryker, passing a property based test and so on can not prove anything, and licenses reduces risks). There are legends that theorem provers can prove the correctness of the program.

It is important to understand the TS philosophy, to understand what the instrument is trying to solve and what is important, what it is not trying to solve.

A note on Soundness
TS skips some operations that are not sure at the compilation stage. Places with unsound behavior were carefully thought out.

Design goals
Not the goal for TS - To make a type system with a guarantee of security, instead focus on the balance between safety and productivity

Example structure:
The problem is not safe behavior, the list may not be complete, this is what I found in articles, reports, TS git issues.
The proposal is a TS issue open 3-4 years ago, with a bunch of comments and interesting explanations by the authors.
Tip - IMHO of the author, what the author considers good practices

Structural vs Nominal typing


Structural vs Nominal typing 1. Problem


Structural typing - when comparing types does not take into account their names or where they were declared, and types are compared according to the "structure".

We want to send the letter `sendEmail` to the correct address` ValidatedEmail`, there is a function for checking the address `validateEmail` which returns the correct address` ValidatedEmail`. Unfortunately TS allows you to send any string to `sendEmail`, because `ValidatedEmail` for TS is no different from` 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. Offer


github.com/microsoft/TypeScript/issues/202
Enter the keyword `nominal` so that types are checked Nominally. Now we can prohibit passing just `string` where` ValidatedEmail` is expected

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. Tip


We can create an `Opaque` type, which will take some` T` and give it uniqueness by combining it with a type created from the passed `K`. `K` can be either a unique symbol (` unique symbol`) or a string (then it will be necessary to ensure that these strings are unique).

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. Problem


We have a Dollar and Euro class, each of the classes has an add method for adding the Dollar to the Dollar and the Euro to the Euro. For TS, these classes are structurally equal and we can add the Dollar to the Euro.

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. Offer


github.com/microsoft/TypeScript/issues/202
The sentence is all the same, with `nominal`, but since Since classes can magically become Nominal (more on that later), the possibilities of making such a transformation in a more explicit way are considered.

Structural vs Nominal typing 1. Tip


If the class has a private field (native with `#` or from TS c `private`), then the class magically becomes Nominal, the name and value can be anything. The `!` (Definite assignment assertion) is used to prevent TS from swearing on an uninitialized field (strictNullChecks, strictPropertyInitialization flags are enabled).

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. Problem


A programming option, in short, is the ability to pass Supertype / Subtype there, where Type is expected. For example, there is a hierarchy Shape -> Circle -> Rectangle, is it possible to transfer or return a Shape / Rectangle if Circle is expected?

Variant in programming habr , SO .

We can pass the type with the field in which the number lies to a function that expects the field as a string or a number, and mutates the transmitted object in the body, changing the field to a string. Those. `{status: number} as {status: number | string} as {status: string} `here is such a trick as turning a number into a string, causing a surprise error.

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. Offer


github.com/Microsoft/TypeScript/issues/10717
It is proposed to introduce `in / out` to explicitly limit covariance / contravariance for generics.

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. Tip


If we work with immutable structures, then there will be no such error (we have already enabled the strictFunctionTypes flag).

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. Bonus


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

But, even if we created Readonly type, TS will not forbid to pass to the function where not expected 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. Problem


Objects may contain additional fields that the types corresponding to them do not have: `{message: string; status: string} as {message: string} `. Due to which some operations may not be safe

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 thought that as a result of the merge, `{... {message: string, status: number}, ... {message: string}}` status will be a number.

In reality, `{... {message:" Not found ", status: 404}, ... {message:" No data ", status:" NotFound "},}` status - string.

Type variance 2. Offer


github.com/microsoft/TypeScript/issues/12936
Introducing an `Exact` type or similar syntax to say that a type cannot contain additional fields.

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. Tip


Merge objects by explicitly listing fields or filtering out unknown fields.

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. Problem


After we have proved something about the external state, calling functions is not safe, because There are no guarantees that functions do not change this external state:

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. Sentence


github.com/microsoft/TypeScript/issues/7770#issuecomment-334919251
Add the `pure` modifier for functions, this will at least allow you to trust such functions

Refinement invalidation. Tip


Use immutable data structures, then the function call will be a priori safe for previous checks.

Bonus


The flow type is so strong that it does not have all the problems listed above, but is so running that I would not recommend using it.

Exceptions. Problem


TS does not help to work with Exceptions in any way; nothing is clear on the function signature.

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. Sentence


github.com/microsoft/TypeScript/issues/13219
It is proposed to introduce a syntax that allows explicitly describing Exceptions in a function signature

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. Bonus


github.com/microsoft/TypeScript/issues/6283
For some reason, in TS, the type definition for Promise ignores the type of error

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. Tip


Take an Either container like Promise, with only the best typing. ( Either implementation example )

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. Problem


If we have a tuple of a fixed size, then TS can guarantee that there is something on the requested index. This will not work for the array and TS will trust us

// 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. Sentence


github.com/microsoft/TypeScript/issues/13778
It is proposed to add `undefined` to the return type` T` for index access to the array. But in this case, when accessing at any index, you will have to use `?` Or do explicit checks.

// 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. Tip


In order not to produce entities beyond need, we take the previously known container `Either` and write a safe function for working with the index, which will return` 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()));

Bonus Reloading functions


If we want to say that a function takes a couple of lines and returns a string or takes a couple of numbers and returns a number, then in the implementation these signatures will be contiguous, and the programmer should guarantee their correctness, but on TS.

PS look at the alternative through generic and 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();


Bonus Type guard


TS trusts the programmer that `isSuperUser` correctly determines who` SuperUser` is and if `Vasya` is added, there will be no prompts.

PS is worth thinking about how we will distinguish types already at the stage of their union - 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(","));
  }
}

Conclusions on the tips


- 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

Immutable data, pure functions, monads ... Congratulations , we proved that FP is cool!

findings


  • TS wants to strike a balance between correctness and productivity
  • Tests can prove safety and correctness only for test data.
  • Types can prove the overall security of a program.
  • Mutation - bad, okay?

As DZ would recommend to play with Flow having corrected a simple error:

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

Example code, solution and analysis of the problem, useful links in the repository .

All Articles