Roberto Tonino

Vienna

Sintassi delle funzioni Typescript

Nota 1: questo articolo è stato tradotto dall’aricolo originale “TypeScript Function Syntaxes” di Kent C. Dodds.

Nota 2: gli snippet di codice non vengono tradotti, in quanto considero lo scrivere codice in italiano una bad practice :)

In JavaScript esistono molti modi di scrivere le funzioni. Aggiungici TypeScript e improvvisamente avrai ancora di puù a cui pensare. Con l’aiuto di qualche amico, ho messo insieme questa lista di comuni tipologie di funzioni che potranno servirti, insieme a dei semplici esempi.

Tieni a mente che ci sono MOLTISSIME combinazioni di sintassi differenti. Includerò soltanto le combinazioni meno ovvie o in qualche maniera uniche.

Prima di tutto, la confusione più grande che ho per quanto riguarda la sintassi è dove mettere il return type della funzione. Quando devo usare : e quando devo usare =>? Di seguito trovi un paio di esempi veloci che potrebbero velocizzarti il lavoro se stai usando questo post come reference:

// Semplice tipo per una funzione, usa =>
type FnType = (arg: ArgType) => ReturnType

// Qualsiasi altra volta, usa :
type FnAsObjType = {
	(arg: ArgType): ReturnType
}
interface InterfaceWithFn {
	fn(arg: ArgType): ReturnType
}

const fnImplementation = (arg: ArgType): ReturnType => {
	/* implementazione */
}

Penso che questa fosse una delle maggiori fonti di confusione per me. Avendolo scritto, ora so che l’unica volta che uso => ReturnType è quando sto definendo un function type come tipo indipendente. In qualsiasi altro caso, è bene usare : ReturnType.

Continua a leggere e troverai qualche esempio su come questo entra in gioco in esempi tipici di codice.

Function declarations (dichiarazioni di funzioni)

// return type inferito
function sum(a: number, b: number) {
	return a + b
}
// return type definito
function sum(a: number, b: number): number {
	return a + b
}

Nei seguenti esempio, useremo return types specifici, ma tecnicamente non è necessario specificarli.

Function Expression (espressione di funzione)

// named function expression
const sum = function sum(a: number, b: number): number {
	return a + b
}
// annonymous function expression
const sum = function (a: number, b: number): number {
	return a + b
}
// arrow function
const sum = (a: number, b: number): number => {
	return a + b
}
// implicit return
const sum = (a: number, b: number): number => a + b
// l'implicit return di un oggetto richiede le parentesi tonde per disambiguare le parentesti graffe
const sum = (a: number, b: number): { result: number } => ({ result: a + b })

Puoi anche aggiungere le type annotations vicino alla variabile, così facendo la funzione prenderà quei tipi:

const sum: (a: number, b: number) => number = (a, b) => a + b

Puoi anche estrarre quel tipo:

type MathFn = (a: number, b: number) => number
const sum: MathFn = (a, b) => a + b

Oppure puoi usare la object type syntax:

type MathFn = {
	(a: number, b: number): number
}
const sum: MathFn = (a, b) => a + b

Che può risultare utile se tu volessi aggiungere una proprietà tipizzata alla funzione:

type MathFn = {
	(a: number, b: number): number
	operator: string
}
const sum: MathFn = (a, b) => a + b
sum.operator = '+'

Ciò può essere fatto anche con un’interfaccia:

interface MathFn {
	(a: number, b: number): number
	operator: string
}
const sum: MathFn = (a, b) => a + b
sum.operator = '+'

Ci sono poi declare function e declare namespace che vogliono dire: “Hey, esiste una variabile con questo nome e questo tipo”. Possiamo usarli per creare il tipo e poi utilizzare typeof per assegnare quel tipo alla nostra funzione. Troverai usato spesso declare nei file .d.ts per dichiarare i tipi delle librerie.

declare function MathFn(a: number, b: number): number
declare namespace MathFn {
	let operator: '+'
}
const sum: typeof MathFn = (a, b) => a + b
sum.operator = '+'

Se dovessi scegliere tra type, interface, e declare function, personalmente sceglierei type, a meno che non abbia bisogno dell’estensibilità offertami da interface. Userei declare soltanto se volessi per davvero comunicare al compilatore che qualcosa esiste e del quale lui non ne è a conoscenza (come ad esempio una libreria).

Parametri opzionali / di default

Parametro opzionale:

const sum = (a: number, b?: number): number => a + (b ?? 0)

Nota che l’ordine qui è importante. Se rendi un parametro opzionale, tutti i parametri seguenti devono essere opzionali. Questo accade perché è possibile chiamare sum(1) ma non sum(, 2). Tuttavia, è possibile chiamare sum(undefined, 2) e se è quello che vuoi rendere possibile, allora puoi farlo:

const sum = (a: number | undefined, b: number): number => (a ?? 0) + b

Parametri opzionali

Mentre stavo scrivendo ciò, pensavo che fosse inutile usare parametri di default senza rendere quel parametro opzionale, ma a quanto pare quando ha un valore di default, TypeScript lo tratta come un parametro opzionale. Di conseguenza, questo funziona:

const sum = (a: number, b: number = 0): number => a + b
sum(1) // results in 1
sum(2, undefined) // results in 2

Di conseguenza quell’esempio è equialente a:

const sum = (a: number, b: number | undefined = 0): number => a + b

Oggi ho imparato qualcosa.

Curiosamente, questo significa che se vuoi che il primo argomento sia opzionale ma il secondo richiesto, puoi farlo senza utilizzare | undefined:

const sum = (a: number = 0, b: number): number => a + b
sum(undefined, 3) // results in 3

Tuttavia, quando estrai quel tipo, avrai bisogno di aggiungere | undefined a mano, dato che = 0 è un expression, non un tipo.

type MathFn = (a: number | undefined, b: number) => number
const sum: MathFn = (a = 0, b) => a + b

Rest params (parametri rest)

I parametri rest sono una feature di JavaScript che ti permette di raggruppare il “resto” degli argomenti di una chiamata di funzione in un array. Puoi usarli in qualsiasi posizione (primi, secondi, ecc…). L’unico requisito è che siano gli ultimi parametri.

const sum = (a: number = 0, ...rest: Array<number>): number => {
	return rest.reduce((acc, n) => acc + n, a)
}

E puoi estrarre il tipo:

type MathFn = (a?: number, ...rest: Array<number>) => number
const sum: MathFn = (a = 0, ...rest) => rest.reduce((acc, n) => acc + n, a)

Object properties e metodi

Object method:

const math = {
	sum(a: number, b: number): number {
		return a + b
	},
}

Proprietà come una function expression:

const math = {
	sum: function sum(a: number, b: number): number {
		return a + b
	},
}

Proprietà come una arrow function expression (con return implicito):

const math = {
	sum: (a: number, b: number): number => a + b,
}

Sfortunatamente, per estrarre quel tipo non puoi tipizzare la funzione stessa, ma devi tipizzare l’oggetto. Non puoi annotare la funzione con un tipo quando è definita in un object literal.

type MathFn = (a: number, b: number) => number

const math: { sum: MathFn } = {
	sum: (a, b) => a + b,
}

Inoltre, se volessi aggiungere una proprietà come alcuni degli esempi precedenti, è impossibile da fare nell’object literal. Devi estrarre la funzione completamente:

type MathFn = {
	(a: number, b: number): number
	operator: string
}
const sum: MathFn = (a, b) => a + b
sum.operator = '+'

const math = { sum }

Potresti aver notato che questo semplice esempio è identico ad un altro esempio qui sopra, con la sola aggiunta di const math = {sum}. Quindi si, non c’è alcun modo di fare tutto questo in linea on la object declaration.

Classi

Le classi sono loro stesse funzioni, ma sono speciali (devono essere invocate con new), ma questa sezione discuterà di come le funzioni sono definite nel corpo della classe.

Qua sotto c’è un metodo, la forma più comune di funzione nel corpo di una classe:

class MathUtils {
	sum(a: number, b: number): number {
		return a + b
	}
}

const math = new MathUtils()
math.sum(1, 2)

Puoi anche usare un membro della classe se vuoi che la funzione sia legata alla specifica istanza della classe:

class MathUtils {
	sum = (a: number, b: number): number => {
		return a + b
	}
}

// doing things this way this allows you to do this:
const math = new MathUtils()
const sum = math.sum
sum(1, 2)

// but it also comes at a cost that offsets any perf gains you get
// by going with a class over a regular object factor so...

Poi, puoi estrarre questi tipi. Questo è il risultato per la versione del metodo del primo esempio:

interface MathUtilsInterface {
	sum(a: number, b: number): number
}

class MathUtils implements MathUtilsInterface {
	sum(a: number, b: number): number {
		return a + b
	}
}

Curiosamente, sembra che tu debba comunque definire i tipi per la funzione, nonstante siamo parte dell’interfaccia che dovrebbe implementare 🤔 🤷‍♂️

Una nota finale. In TypeScript, puoi anche usare public, private, e protected. Personalmente non uso classi così spesso e non mi piace utilizzare queste specifiche feature di TypeScript. JavaScript otterrà presto una sintassi dedicata ai membri private, il che è ottimo. (Approfondsci qui).

Moduli

Importare ed esportare definizioni di funzioni funziona allo stesso modo di tutto il resto. Le cose diventano uniche per quanto riguarda TypeScript se vuoi scrivere un file .d.ts con una module declaration. Prendiamo come esempio la nostra funzione sum:

const sum = (a: number, b: number): number => a + b
sum.operator = '+'

Questo è quello che faremmo, assumendo che l’export sia default:

declare const sum: {
	(a: number, b: number): number
	operator: string
}
export default sum

E se vogliamo che sia un named export:

declare const sum: {
	(a: number, b: number): number
	operator: string
}
export { sum }

Overload

Ho scritto specificatamente di questo e puoi leggerlo: Define function overload types with TypeScript. Qui sotto troviamo l’esempio in quel post:

type asyncSumCb = (result: number) => void
// define all valid function signatures
function asyncSum(a: number, b: number): Promise<number>
function asyncSum(a: number, b: number, cb: asyncSumCb): void
// define the actual implementation
// notice cb is optional
// also notice that the return type is inferred, but it could be specified
// as `void | Promise<number>`
function asyncSum(a: number, b: number, cb?: asyncSumCb) {
	const result = a + b
	if (cb) return cb(result)
	else return Promise.resolve(result)
}

In pratica quello che fai è definire la funzione più volte e implementarla soltanto l’ultima volta. È importante che i tipi dell’implementazione supportino tutti i tipi che vengono sovrascritti, che è il motivo per il quale cb qua sopra è opzionale.

Generatori

Non ho mai usato un generatore in codice rilasciato in produzione… Ma quando ci ho giocato un po’ nel playground di TypeScript non c’era molto da dire per il caso semplice:

function* generator(start: number) {
	yield start + 1
	yield start + 2
}

var iterator = generator(0)
console.log(iterator.next()) // { value: 1, done: false }
console.log(iterator.next()) // { value: 2, done: false }
console.log(iterator.next()) // { value: undefined, done: true }

TypeScript inferisce correttamente che iterator.next() ritorna un oggetto della seguente forma:

type IteratorNextType = {
	value: number | void
	done: boolean
}

Se vuoi type safety per il valore di completamento dell’espressione yield, aggiungi una type annotation alla variabile a cui lo assegni:

function* generator(start: number) {
	const newStart: number = yield start + 1
	yield newStart + 2
}

var iterator = generator(0)
console.log(iterator.next()) // { value: 1, done: false }
console.log(iterator.next(3)) // { value: 5, done: false }
console.log(iterator.next()) // { value: undefined, done: true }

E ora se provi ad invocare iterator.next('3') invece di iterator.next(3) avrai un errore di compilazione 🎉

Async

Le funzioni async/await in TypeScript funzionano esattamente come in JavaScript e l’unica differenza nella loro tipizzazione è che il return type sarà sempre un generic di una Promise.

const sum = async (a: number, b: number): Promise<number> => a + b
async function sum(a: number, b: number): Promise<number> {
	return a + b
}

Generic

Con una dichiarazione di funzione:

function arrayify2<Type>(a: Type): Array<Type> {
	return [a]
}

Sfortunatamente, con un’arrow function (quando TypeScript è configurato per usare JSX), l’iniziale < della funzione è ambiguo per il compilatore. “È una sintassi per un generic? Oppure è JSX?” Devi quindi aggiungere qualcosa per aiutarlo nella disambiguazione. Penso che la soluzione più semplice sia usare extends unknown:

const arrayify = <Type extends unknown>(a: Type): Array<Type> => [a]

Che mostra in modo conveniente la sintessi per extends nei generic.

Type Guard

Una type guard è un meccanismo per fare type narrowing. Per esempio, ti permette di restringere qualcosa che è string | number in qualcosa che sia string oppure number. Ci sono meccanismi builtin per ciò (come typeof x === 'string'), ma puoi anche crearne uno tuo. Qui trovi uno dei miei preferiti (alzo il cappello al mio amico Peter che me l’ha mostrato):

Hai un array con dei tipi falsy e vuoi che se ne vadano:

// Array<number | undefined>
const arrayWithFalsyValues = [1, undefined, 0, 2]

In JavaScript normale puoi fare:

// Array<number | undefined>
const arrayWithoutFalsyValues = arrayWithFalsyValues.filter(Boolean)

Sfortunatamente, TypeScript non considera questa una type narrowing guard, dunque il tipo rimane Array<number | undefined> (nessun narrowing applicato).

Possiamo quindi scrivere la nostra funzione e dire al compilatore che ritorna true/false se l’argomento è di un tipo specifico. Per noi, diremo che la nostra funzione ritorna true se il tipo dell’argomento non è incluso in uno dei tipi falsy.

type FalsyType = false | null | undefined | '' | 0
function typedBoolean<ValueType>(value: ValueType): value is Exclude<ValueType, FalsyType> {
	return Boolean(value)
}

E con esso possiamo fare questo:

// Array<number>
const arrayWithoutFalsyValues = arrayWithFalsyValues.filter(typedBoolean)

Woo!

Assertion function

Hai present come ogni tanto fai dei controlli a runtime per essere super sicuro di qualcosa? Ad esempio, quando un oggetto può avere una proprietà con valore null vuoi controllare se è null e in caso throware un errore se è null. Qua si vede come potresti fare una cosa del genere:

type User = {
	name: string
	displayName: string | null
}

function logUserDisplayNameUpper(user: User) {
	if (!user.displayName) throw new Error('Oh no, user has no displayName')
	console.log(user.displayName.toUpperCase())
}

A TypeScript va bene user.displayName.toUpperCase() perché l’if è una type guard che capisce. Ora, supponiamo che si voglia prendere quell’if e metterlo in una funzione:

type User = {
	name: string
	displayName: string | null
}

function assertDisplayName(user: User) {
	if (!user.displayName) throw new Error('Oh no, user has no displayName')
}

function logUserDisplayName(user: User) {
	assertDisplayName(user)
	console.log(user.displayName.toUpperCase())
}

Ora, TypeScript non è più content perché la chiamata a assertDisplayName non è una type guard sufficiente. Sosterrei che questa è una limitazione lato TypeScript. Hey, nessuna tecnologia perfetta. In qualunque caso, possiamo aiutare TypeScript un pochino dicendogli che la nostra funzione fa un’asserzione:

type User = {
	name: string
	displayName: string | null
}

function assertDisplayName(user: User): asserts user is User & { displayName: string } {
	if (!user.displayName) throw new Error('Oh no, user has no displayName')
}

function logUserDisplayName(user: User) {
	assertDisplayName(user)
	console.log(user.displayName.toUpperCase())
}

E questo è un altro modo per trasformare la nostra funzione in una funzione di type narrowing!

Conclusioni

Questo non è sicuramente tutto, ma è buona parte della sitassi che mi trovo a scrivere tutti i giorni riguardo le funzioni in TypeScript. Spero che ti sia stato d’aiuto! Aggiungi questi ai segnalibri e condividilo con i tuoi amici 😘