Motivazioni
Il sistema di tipi di TypeScript è strutturale ed è uno dei suoi principali vantaggi. Questa caratteristica offre numerosi strumenti potenti per rendere gli stati non validi irrappresentabili, permettendo così di rilevare bug potenziali in fase di compilazione e non al momento dell'esecuzione.
Tuttavia, questo sistema non è sempre sufficiente. Esistono ad esempio casi d'uso nel mondo reale in cui è desiderabile che due variabili siano differenziate perché hanno un nome di tipo diverso, anche se hanno strettamente la stessa struttura.
Esempio:
type PostId = number;
type CommentId = number;
const postId: PostId = post.id;
const commentId: CommentId = postId;
È possibile assegnare alla variabile commentId il contenuto della variabile postId, dopotutto, sono entrambi dei number se si guarda la definizione del loro tipo. Tuttavia, vogliamo davvero che ciò sia possibile?
Dal punto di vista funzionale della nostra applicazione, ciò non ha veramente senso. Un post del blog non è la stessa cosa di un commento. Ottimo! È esattamente questo tipo di vincolo funzionale che il type branding ci permette di implementare.
Branding
Definizione
Il concetto di branding consiste nell'aggiungere un campo distintivo al nostro tipo per differenziarlo dagli altri. Questo campo sarà utile unicamente al compilatore TypeScript per determinare staticamente se due tipi sono compatibili o meno.
Ecco il nostro stesso esempio utilizzando il tipo branding:
type PostId = number & { __brand: 'PostId' };
type CommentId = number & { __brand: 'CommentId' };
const value = 1 as PostId;
const postId: PostId = value;
const commentId: CommentId = value;
Sebbene l'assegnazione di un tipo PostId a un tipo CommentId non causi alcun problema a runtime, genera ora un errore di compilazione ed evita errori di business.
È comune definire un tipo generico che permette di generare tipi branded:
type Brand<T, U> = T & { __brand: U };
type PostId = Brand<number, 'PostId'>;
type CommentId = Brand<number, 'CommentId'>;
Nota: un tipo come Brand può esistere perché l'intersezione tra number (qui) e un oggetto JS è permessa da JavaScript. Nella maggior parte degli altri linguaggi tipizzati, una tale intersezione equivarrebbe a "never" e non avrebbe senso.
Limitazioni
Il cambiamento di un tipo in un tipo branded richiede di effettuarne il cast manualmente;
È possibile leggere la proprietà __brand ;
Non è possibile alcuna conversione implicita, per esempio:
type Post = Brand<{ author: string; content: string; }, 'Post'>;
const createPost = (post: Post) => { ... };
createPost({ author: 'matthieu', content: 'Hello world!' });
Flavoring
Definizione
Il flavoring è in tutto e per tutto simile al branding, con la differenza che la proprietà __brand è resa opzionale. Questa tecnica ti permette di avere una conversione implicita per i tipi e gli oggetti:
type Flavor<T, U> = T & { __flavor?: U };
type Post = Flavor<{ author: string; content: string; }, 'Post'>;
type PostComment = Flavor<{ author: string; content: string; }, 'PostComment'>;
const createPost = (post: Post) => { ... };
createPost({ author: 'matthieu', content: 'Hello world!' });
const comment: PostComment = { author: 'matthieu', content: 'Hello world!' };
createPost(comment);
Nonostante ciò, si nota che si è comunque conservata l'incompatibilità tra diversi tipi aventi la stessa struttura. Ciò è valido anche per i tipi primitivi:
type Flavor<T, U> = T & { __flavor?: U };
type PostId = Flavor<number, 'PostId'>
type CommentId = Flavor<number, 'CommentId'>
const postId: PostId = 1;
const commentId: CommentId = postId;
Limitazioni
È sempre possibile leggere la proprietà __flavor ;
Meno restrittivo del branding, la conversione implicita può portare a errori e deve essere usata con giudizio
Conclusione
Se è comunemente ammesso che è preferibile utilizzare il branding per i tipi primitivi, il flavoring può essere preferibile per gli oggetti al fine di trarre beneficio dalla conversione implicita. Si può utilizzare un tipo condizionale per fare questo lavoro al posto nostro:
type Brand<T, U> = T & { __brand: U };
type Flavor<T, U> = T & { __flavor?: U };
type Nominal<T, U> = T extends object ? Flavor<T, U> : Brand<T, U>;
Referenze