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 enough1,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 implementationfunction 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 return1.1 + 2.7
NaN + 2
99999999999999992 + 99999999999999992
2n + 2
{} + true
2 + '2'
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 SoundnessTS skips some operations that are not sure at the compilation stage. Places with unsound behavior were carefully thought out.Design goalsNot the goal for TS - To make a type system with a guarantee of security, instead focus on the balance between safety and productivityExample 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 practicesStructural 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/202Enter the keyword `nominal` so that types are checked Nominally. Now we can prohibit passing just `string` where` ValidatedEmail` is expectednominal 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>;
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);
dollars100.add(dollars100);
euro100.add(euro100);
dollars100.add(euro100);
Structural vs Nominal typing 2. Offer
github.com/microsoft/TypeScript/issues/202The 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 {
private desc!: never;
value: number;
constructor(value: number) {
this.value = value;
}
add(dollar: Dollar) {
return new Dollar(dollar.value + this.value);
}
}
class Euro {
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);
dollars100.add(dollars100);
euro100.add(euro100);
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);
console.log(error.status.toFixed());
Type variance 1. Offer
github.com/Microsoft/TypeScript/issues/10717It 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 };
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 }>) {
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 mutablegithub.com/Microsoft/TypeScript/issues/13347github.com/microsoft/TypeScript/pull/6532#issuecomment-174356151But, 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);
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 safeconst 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 };
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/12936Introducing 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, };
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 };
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) {
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);
logAge(person.name, person.age);
}
Refinement invalidation. Sentence
github.com/microsoft/TypeScript/issues/7770#issuecomment-334919251Add the `pure` modifier for functions, this will at least allow you to trust such functionsRefinement 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/13219It is proposed to introduce a syntax that allows explicitly describing Exceptions in a function signatureimport { 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 {
return error as never;
}
}
}
console.log(getJokeSafe(true));
Exceptions. Bonus
github.com/microsoft/TypeScript/issues/6283For some reason, in TS, the type definition for Promise ignores the type of errorconst promise1: Promise<number> = Promise.resolve(42);
const promise: Promise<never> = Promise.reject(new TypeError());
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)
.mapLeft(error => {
if (error instanceof JokeError) {
console.log("JokeError");
} else {
exhaustiveCheck(error);
}
})
.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
export const constNumbers: readonly [1, 2, 3]
= [1, 2, 3] as const;
console.log(constNumbers[100].toFixed());
const dynamicNumbers: number[] = [1, 2, 3];
console.log(dynamicNumbers[100].toFixed());
Unsafe operations. Sentence
github.com/microsoft/TypeScript/issues/13778It 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.
const dynamicNumbers: number[] = [1, 2, 3];
console.log(dynamicNumbers[100].toFixed());
console.log(dynamicNumbers[100]?.toFixed());
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);
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 uniontype 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) {
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 monadsImmutable 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:
declare function log(arg: { name: string, surname?: string }): void;
const person: { name: string } = { name: 'Negasi' };
log(person);
Example code, solution and analysis of the problem, useful links in the repository .