This series is my Next.js study resume, and despite it’s keen to a vanilla Next.js, all the features are applicable with Sitecore SDK. It is similar to the guide I recently wrote about GraphQL and aims to reduce the learning curve for those switching to it from other tech stacks.
- In part 1 we covered some fundamentals of Next.js – rendering strategies along with the nuances of getStaticProps, getStaticPaths, getServerSideProps as well as data fetching.
- In part 2 we spoke about UI-related things coming to OOB with Next.js – layouts, styles, and fonts powerful features, Image and Script components, and of course – TypeScript.
- We went through the nuances of Next.js routing and explained middleware in part 3
- Part 4 was all about caching, authentication, and going live tasks
In this post, we are going to talk about TypeScript. It was already well mentioned in the previous parts, but this time we’d put it under a spotlight.
Intro
TypeScript became an industry standard for strong typing in the current state of the JavaScript world. There is currently no other solution allowing us to effectively implement typing into a project. One can, of course, use some kind of contracts, conventions, or JSDoc to describe types. But all this will be times worse for code readability compared to typical TypeScript annotations. They will allow you not to have your eyes darting up and down, you will simply read the signature and immediately understand everything.
Another point comes to JavaScript support in editors and IDEs being usually based on TypeScript. JavaScript support in all modern IDEs is built on TypeScript! JavaScript support in VS Code is implemented using the TypeScript Language Service. WebStorm’s JavaScript support largely relies on the TypeScript framework and uses its standard library. This reality is – that JavaScript support in editors goes built on top of TypeScript. One more reason to learn TypeScript is that when the editor complains about the JavaScript type mismatch, we’ll have to read the declarations from TypeScript.
However, TypeScript is not a replacement for other code quality tools. TypeScript is just one of the tools that allows you to maintain some conventions in a project and make sure that there are strong types. You are still expected to write tests, do code reviews, and be able to design the architecture correctly.
Learning TypeScript, even in 2024, can be difficult, for many various reasons. Folks like myself who grew from C# may find things that don’t function as they should expect. People who have programmed in JavaScript for most of their lives get scared when the compiler yells at them.
TypeScript is a superset of JavaScript, this means that JavaScript is part of TypeScript: it is in fact made up of JavaScript. Going with TypeScript does not mean throwing out JavaScript with its specific behavior – TypeScript helps us to understand why JavaScript behaves this way. Let’s look at some mistakes people make when getting started with TypeScript. As an example, take error handling. It would be very our natural logical expectation to handle errors, similarly, we are used to doing in other programming languages:
try { // let's pull some API with Axios, and some error occured, say Axios fetch failed } catch (e: AxiosError) { // ^^^^^^^^^^ Error }
The above syntax is not possible because that’s not how errors work in JavaScript. Code that would be logical to write in TypeScript cannot be written that way in JavaScript.
A quick recap of a Typescript
- Strongly typed language developed by Microsoft.
- Code written in TypeScript compiles to native JavaScript
- TypeScript extends JavaScript with the ability to statically assign types.
TypeScript supports modern editions of the ECMAScript standards, and code written using them is compiled taking into account the possibility of its execution on platforms that support older versions of the standards. This means that a TS programmer can take advantage of the features of ES2015 and newer standards, such as modules, arrow functions, classes, spread, and destructuring, and do what they can in existing environments that do not yet support these standards.
The language is backward compatible with JavaScript and supports modern editions of the ECMAScript standards. If you feed the compiler pure JavaScript, the compiler will “eat out” your own JS and will not say that it is an error – that would be a valid TypeScript code, however with no type benefits. Therefore one can write a mixed code of ES2015 and newer standards, such as modules, arrow functions, classes, spread, destructuring, etc. using TypeScript syntax, and implementing methods without strongly typing just using pure JS – and that will also be valid.
So, what benefits does language provide?
- System for working with modules/classes – you can create interfaces, modules, classes
- You can inherit interfaces (including multiple inheritance), classes
- You can describe your own data types
- You can create generic interfaces
- You can describe the type of a variable (or the properties of an object), or describe what interface the object to which the variable refers should have
- You can describe the method signature
- Using TypeScript (as opposed to JavaScript) significantly improves the development process due to IDE receives type information from the TS compiler in real time.
On the other hand, there are some disadvantages:
- In order to use some external tool (say, “library” or “framework”) with TypeScript, the signature of each of the methods of each module of this tool must be described so that the compiler does not throw errors, otherwise, it simply won’t know about your tool. For the majority of popular tools, the interface description most likely could be found in the repository. Otherwise, you’ll have to describe it yourself
- Probably the biggest disadvantage is the learning curve and building a habit of thinking and writing TypeScript
- At least as it goes with me, more time is spent on development compared to vanilla JavaScript: in addition to the actual implementation, it is also necessary to describe all the involved interfaces and method signatures.
One of the serious advantages of TS over JS is the ability to create, in various IDEs, a development environment that allows you to identify common errors directly in the process of entering code. Using TypeScript in large projects can lead to increased reliability of programs that can still be deployed in the same environments where regular JS applications run.
Types
There are expectations that it is enough to learn a few types to start writing in TypeScript and automatically get good code. Indeed, one can simply write types in the code, explaining to the compiler that in this place we are expecting a variable of a certain type. The compiler will prompt you on whether you can do this or not:
let helloFunction = () => { "hello" }; let text: string = helloFunction (); // TS2322: Type 'void' is not assignable to type 'string'.
However, the reality is that it will not be possible to outsource the entire work to the compiler. And let’s see what types you need to learn to progress with TypeScript:
- basic primitives from JavaScript:
boolean
,number
,string
,symbol
,bigint
,undefined
andobject
. - instead of the
function
type, TypeScript hasFunction
and a separate syntax similar to the arrow function, but for defining types. The object type will mean that the variable can be assigned any object literals in TypeScript. - TypeScript-specific primitives:
null
,unknown
,any
,void
,unique symbol
,never
,this
. - Next are the types standard for many object-oriented languages:
array
,tuple
,generic
- Named types in TypeScript refer to the practice of giving a specific, descriptive name to a type for variables, function parameters, and return types:
type User = { name: string; age: number; }
- TypeScript doesn’t stop there: it offers
union
andintersection
. Special literal types often work in conjunction withstring
,number
,boolean
,template string
. These are used when a function takes not just a string, but a specific literal value, like “foo” or “bar”, and nothing else. This significantly improves the descriptive power of the code. - TypeScript also has
typeof
,keyof
,indexed
,conditional
,mapped
,import
,await
,const
,predicate
.
These are just the basic types; many others are built on their basis: for example, a composite Record<T>
, or the internal types Uppercase<T>
and Lowercase<T>
, which are not defined in any way: they are intrinsic
types.
P.S. – do not use Function
, pass a predefined type, or use an arrow notation instead:
// replace unknown with the types you're using with the function F extends (...args: unknown[]) => unknown // example of a function with 'a' and 'b' arguments that reutrns 'Hello' const func = (a: number, b: number): string => 'Hello'
Map and d.ts files
TypeScript comes with its own set of unique file types that can be puzzling at first glance. Among these are the *.map
and *.d.ts
files. Let’s demystify these file types and understand their roles in TypeScript development.
What are .map Files in TypeScript?
.map
files, or source map files, play a crucial role in debugging TypeScript code. These files are generated alongside the JavaScript output when TypeScript is compiled. The primary function of a .map
file is to create a bridge between the original TypeScript code and the compiled JavaScript code. This linkage is vital because it allows developers to debug their TypeScript code directly in tools like browser developer consoles, even though the browser is executing JavaScript.
When you’re stepping through code or setting breakpoints, the .map
file ensures that the debugger shows you the relevant TypeScript code, not the transpiled JavaScript. This feature is a game-changer for developers, as it simplifies the debugging process and enhances code maintainability.
Understanding .d.ts Files in TypeScript
On the other side, we have *.d.ts
files, known as declaration files in TypeScript. These files are pivotal for using JavaScript libraries in TypeScript projects. Declaration files act as a bridge between the dynamically typed JavaScript world and the statically typed TypeScript world. They don’t contain any logic or executable code but provide type information about the JavaScript code to the TypeScript compiler.
For instance, when you use a JavaScript library like Lodash or jQuery in your TypeScript project, the *.d.ts
files for these libraries describe the types, function signatures, class declarations, etc., of the library. This allows TypeScript to understand and validate the types being used from the JavaScript library, ensuring that the integration is type-safe and aligns with TypeScript’s static typing system.
The Key Differences
The primary difference between *.map
and *.d.ts
files lies in their purpose and functionality. While .map
files are all about enhancing the debugging experience by mapping TypeScript code to its JavaScript equivalent, .d.ts
files are about providing type information and enabling TypeScript to understand JavaScript libraries.
In essence, .map
files are about the developer’s experience during the debugging process, whereas .d.ts
files are about maintaining type safety and smooth integration when using JavaScript libraries in TypeScript projects.
Cheatsheet
I won’t get into the details of the basics and operations which can be visualized in this cheat sheet instead:
Better than a thousand words!
Let’s take a look at some other great features not mentioned in the above cheatsheet.
What the heck is Any?
In TypeScript you can use any
data type. This allows you to work with any type of data without errors. Just like regular javascript – in fact what you do by using any is downgrading your TypeScript to just JavaScript by eliminating type safety. The best way is to look at it in action:
let car: any = 2024; console.log(typeof car) // number car= "Mercedes"; console.log(typeof car) // string car = false; console.log(typeof car) // boolean car= null; console.log(typeof car) // object car = undefined; console.log(typeof car) // undefined
The car
variable can be assigned any data type. Any is an evil data type, indeed! If you are going to use any
data type, then TypeScript immediately becomes unnecessary. Just write code in JavaScript.
TypeScript can also determine what data type will be used if we do not specify it. We can replace the code from the first example with this one.
const caterpie01: number = 2021; // number const caterpie001 = 2021; // number - that was chosen by typescript for us const Metapod01: string = "sleepy"; // string const Metapod001 = "sleepy"; // string - that was chosen by typescript for us const Wartortle01: boolean = true; // boolean const Wartortle001 = true; // boolean - that was chosen by typescript for us
This is a more readable and shorter way of writing. And of course, we won’t be able to assign any other data type to a variable.
let caterpie = 2021; // in typescript this vairable becomes number after assignment caterpie = "text"; // type error, as the type was already defined upon the assingment
On the other hand, if we don’t specify a data type for the function’s arguments, TypeScript will use any type. Let’s look at the code:
const sum = (a, b) => { return a + b; } sum(2021, 9);
In strict mode, the above code will error out with “Parameter 'a' implicitly has an 'any' type; Parameter 'a' implicitly has an 'any' type
” message, but will perfectly work outside of strict mode in the same manner as JavaScript code would. I assume that was intentionally done for the compatibility
Null checks and undefined
As simple as that:
if (value) { }
The expression in parentheses will be evaluated as true if it is not one of the following:
null
undefined
NaN
- an empty string
- 0
false
TypeScript supports the same type conversion rules as JavaScript does.
Type checking
Surprise-surprise, TypeScript is all about the types and annotations. Therefore, the next piece of advice may seem weird, but, is legit: avoid explicit type checking, if you can.
Instead, always prefer to specify the types of variables, parameters, and return values to harness the full power of TypeScript. This makes future refactoring easier.
function travelToTexas(vehicle: Bicycle | Car) { if (vehicle instanceof Bicycle) { vehicle.pedal(currentLocation, new Location('texas')); } else if (vehicle instanceof Car) { vehicle.drive(currentLocation, new Location('texas')); } }
The below rewrite looks much more readable and therefore easy to maintain. And there are no ugly type checking clauses:
type Vehicle = Bicycle | Car; function travelToTexas(vehicle: Vehicle) { vehicle.move(currentLocation, new Location('texas')); }
GenericsUse them wherever possible. This will help you better identify the type being used in your code. Unfortunately, they are not often used, but in vain.
function returnType<T>(arg: T): T { return arg; } returnType<string>('MJ knows Sitecore') // works well returnType<number>('MJ knows Sitecore') // errors out // ^ Argument of type 'string' is not assignable to parameter of type 'number'.
If you are using a specific type, be sure to use extends:
type AddDot<T extends string> = `${T}.` // receives only strings, otherwise errors out
Ternary operators with extends
extends
is very useful, it helps to determine what a type is inherited from in the type hierarchy (any
-> number
-> …) and make a comparison. Thanks to the combination of extends and ternary operators, you can create such awesome conditional constructs:
type IsNumber<T> = T extends number ? true : false IsNumber<5> // true IsNumber<'lol'> // false
Readonly and Consts
Use readonly
by default to avoid accidentally overwriting types in your interface.
interface User { readonly name: string; readonly surname: string; }
Let’s say you have an array that comes from the backend [1, 2, 3, 4]
and you need to use only these four numbers, that is, make the array immutable. The as const
construction can easily handle this:
const arr = [1, 2, 3, 4] // curent type is number so that you can assign any number arr[3] = 5 // [1, 2, 3, 5] const arr = [1, 2, 3, 4] as const // now type is locked as readonly [1, 2, 3, 4] arr[3] = 5 // errors out
Satisfies
That is a relatively recent feature (since version 4.9), but is so helpful as it allows you to impose restrictions without changing the type. This can be very useful when you manage different types that do not share common methods. For example:
type Numbers = readonly [1, 2, 3]; type Val = { value: Numbers | string }; // the valid accepted values could be numbers 1, 2, 3, or a string const myVal: Val = { value: 'a' };
So far so good. Let’s say we have a string, and must convert it to capital letters. Intuitively trying to use the below code without Satisfies
will get you an error:
myVal.value.toUpperCase() // ^ Property 'toUpperCase' does not exist on type 'Numbers'.
So the right way to deal with it would be to use Satisfies, then everything will be fine:
const myVal = { value: 'a' } satisfies {value: string}; myVal.value.toUpperCase() // works well and outputs 'A'
Unions
Sometimes you can see code like this:
interface User { loginData: "login" | "username"; getLogin(): void; getUsername(): void; }
This code is bad because you can use a username but still call getLogin()
and vice versa. To prevent this, it is better to use unions instead:
interface UserWithLogin { loginData: "login"; getLogin(): void; } interface UserWithUsername { loginData: "username"; getUsername(): void; } type User = UserWithLogin | UserWithUsername;
What is even more impressive – unions are iterable, meaning they can be used to loop through a test:
type Numbers = 1 | 2 | 3 type OnlyRuKeys = {[R in Numbers]: boolean} // {1: boolean, 2: boolean, 3: boolean}
Unity Types
TypeScript Utility Types are a set of built-in types that can be used to manipulate data types in code.
Required<T>
– makes all properties of an object of type T required.Partial<T>
– makes all properties of an object of type T optional.Readonly<T>
– makes all properties of an object of type T read-onlyNonNullable<Type>
– Retrieves a type from Type, excluding null and undefined.Parameters<Type>
– retrieves the types of function arguments TypeReturnType<Type>
– retrieves the return type of the function TypeInstanceType<Type>
– retrieves the type of an instance of the Type classRecord<Keys, Type>
– creates a type that is a record with keys defined in the first parameter and values of the type defined in the second parameter.Pick<T, K extends keyof T>
– selects properties of an object of type T with the keys specified in K.Omit<T, K extends keyof T>
– selects properties of an object of type T, excluding those specified in KExclude<UnionType, ExcludedMembers>
– Excludes certain types from the union typeUppercase<StringType>
,Lowercase<StringType>
,Capitalize<StringType>
,Uncapitalize<StringType>
– string manipulation utility types that change the case of the string according to their name.
I won’t get into the details on all of them but will showcase just a few for better understanding:
// 1. Required interface Person { name?: string; age?: number; } let requiredPerson: Required<Person>; // now requiredPerson could be as { name: string; age: number; } // 2. Partial interface Person { name: string; age: number; } let partialPerson: Partial<Person>; // now partialPerson could be as { name?: string; age?: number; } // 3. NonNullable let value: string | null | undefined; let nonNullableValue: NonNullable<typeof value>; // now nonNullableValue is a string // 4. Awaited async function getData(): Promise<string> { return 'hello'; } let awaitedData: Awaited<ReturnType<typeof getData>>; // now awaitedData could be 'hello' // 5 Case management type Uppercased = Uppercase<'hello'>; // 'HELLO' type Lowercased = Lowercase<'Hello'>; // 'hello' type Capitalized = Capitalize<'hello'>; // 'Hello' type Uncapitalized = Uncapitalize<'Hello'>; // 'hello'
These above are just a few examples of utility types in TypeScript. To find out more please refer to the official documentation.
Interface vs types
One of the questions that raises most of the confusion for the C# developer trying TypeScript. What’s the difference between these two below signatures:
interface X { a: number b: string } type X = { a: number b: string };
You can use both to describe the shape of an object or a function signature, it’s just the syntax differs.
Unlike interface
, the type
alias can also be used for other types such as primitives, unions, and tuples:
// primitive type Name = string; // object type PartialPointX = { x: number; }; type PartialPointY = { y: number; }; // union type PartialPoint = PartialPointX | PartialPointY; // tuple type Data = [number, string];
Both can be extended, but again, the syntax differs. Also, an interface can extend a type alias, and vice versa:
// Interface extends interface interface PartialPointX { x: number; } interface Point extends PartialPointX { y: number; } // Type alias extends type alias type PartialPointX = { x: number; }; type Point = PartialPointX & { y: number; }; // Interface extends type alias type PartialPointX = { x: number; }; interface Point extends PartialPointX { y: number; } // Type alias extends interface interface PartialPointX { x: number; } type Point = PartialPointX & { y: number; };
A class can implement an interface or type alias, both in the same exact way. However, a class and interface are considered static blueprints. Therefore, they can not implement or extend a type alias that names a union type.
Unlike a type alias, an interface can be defined multiple times and will be treated as a single interface (with members of all declarations being merged):
// These two declarations become: // interface Point { x: number; y: number; } interface Point { x: number; } interface Point { y: number; } const point: Point = { x: 1, y: 2 };
So, when I should use one against another? If simplified, use types when you might need a union or intersection. Use interfaces when you want to use extends or implements. There is no hard and fast rule though, use what works for you. I admit, that may still be confusing, please read this discussion for more understanding.
Error Handling
Throwing errors is always good: if something goes wrong at runtime, you can terminate the execution at the right moment and investigate the error using the stack trace in the console.
Always use rejects with errors
JavaScript and TypeScript allow you to throw any object. A promise can also be rejected with any reason object. It is recommended to use throw syntax with type Error
. This is because your error can be caught at a higher level of code with catch syntax. Instead of this incorrect block:
function calculateTotal(items: Item[]): number { throw 'Not implemented.'; } function get(): Promise<Item[]> { return Promise.reject('Not implemented.'); }
use the below:
function calculateTotal(items: Item[]): number { throw new Error('Not implemented.'); } function get(): Promise<Item[]> { return Promise.reject(new Error('Not implemented.')); } // the above Promise could be rewritten with an async equivalent: async function get(): Promise<Item[]> { throw new Error('Not implemented.'); }
The advantage of using Error
types is that they are supported by try
/catch
/finally
syntax and implicitly all errors and have a stack property which is very powerful for debugging. There are other alternatives: don’t use throw syntax and always return custom error objects instead. TypeScript makes this even easier and it works like a charm!
Dealing with Imports
Last but not least, I want to share some tips and best practices for using imports. With simple, clear, and logical import statements, you can faster inspect the dependencies of your current code.
Make sure you use the following good practices for import statements:
- Import statements should be in alphabetical order and grouped.
- Unused imports must be removed (linter will come to help you)
- Named imports must be in alphabetical order, i.e.
import {A, B, C} from 'mod';
- Import sources should be in alphabetical order in groups, i.e.:
import * as foo from 'a';
import * as bar from 'b';
- Import groups are indicated by blank lines.
- Groups must follow the following order:
- Polyfills (i.e.
import 'reflect-metadata';
) - Node build modules (i.e.
import fs from 'fs';
) - External modules (i.e.
import { query } from 'itiriri';
) - Internal modules (i.e.
import { UserService } from 'src/services/userService';
) - Modules from the parent directory (i.e.
import foo from '../foo'; import qux from '../../foo/qux';
) - Modules from the same or related directory (i.e.
import bar from './bar'; import baz from './bar/baz';
)
- Polyfills (i.e.
These rules are not obvious to beginners, but they come with time once you start paying attention to the minors. Just compare the ugly block of imports:
import { TypeDefinition } from '../types/typeDefinition'; import { AttributeTypes } from '../model/attribute'; import { ApiCredentials, Adapters } from './common/api/authorization'; import fs from 'fs'; import { ConfigPlugin } from './plugins/config/configPlugin'; import { BindingScopeEnum, Container } from 'inversify'; import 'reflect-metadata';
against them nicely structured, as below:
import 'reflect-metadata'; import fs from 'fs'; import { BindingScopeEnum, Container } from 'inversify'; import { AttributeTypes } from '../model/attribute'; import { TypeDefinition } from '../types/typeDefinition'; import { ApiCredentials, Adapters } from './common/api/authorization'; import { ConfigPlugin } from './plugins/config/configPlugin';
Which one gets faster to read?
Conclusion
In my opinion, TypeScript’s superpower is that it provides feedback as you write the code, not at runtime: IDE nicely prompts what argument to use when calling a function, and types are defined and navigable. All that comes at the cost of a minor compilation overhead and a slightly increased learning curve. That is a fair deal, TypeScript will stay with us for a long and won’t go away – the earlier you learn it the more time and effort it will save later.
Of course, I left plenty of raw TypeScript features aboard and did that intentionally so as not to overload the readers with it. If the majority of you are coming in the capacity of professionals, either starting with Next.js development in general or switching from other programming languages and tech. stacks (like .NET with C#) where I was myself a year ago – that is definitely the right volume and agenda for you to start with. There are of course a lot of powerful TypeScript features for exploration beyond today’s post, such as:
- Decorators
- Namespaces
- Type Guards and Differentiating Types
- Type Assertions
- Ambient Declarations
- Advanced Types (e.g., Conditional Types, Mapped Types, Template Literal Types)
- Module Resolution and Module Loaders
- Project References and Build Optimization
- Declaration Merging
- Using TypeScript with WebAssembly
But I think that is enough for this post. Hope you’ll enjoy writing strongly typed code with TypeScript!