typescript
Advanced TypeScript Types
I’ve spent countless hours debugging undefined
properties and unexpected data types. That’s one of the reasons I love TypeScript over plain JS.
This post serves as a personal quick reference guide to some useful advanced TS tricks, kinda like a cheatsheet for when I’m working in a new project and I need some reference to do something.
1. Optional Properties
It is NOT the same to have a optional key, than a optional value. I have seen infinite examples where this confusion leads to bugs because of a misscomunication between BE and FE for example.
Key Optional
When you define a property with ?:
, you’re stating that the key itself might not exist on the object. If the key is present, its value must be type SomeType
.
// --------------------------------------
// Type definition
// --------------------------------------
type UserProfile = {
id: string
name: string
email?: string // `email` **key** might not exist
}
// --------------------------------------
// Examples
// --------------------------------------
const user1: UserProfile = {
id: '123',
name: 'Alice',
} // Valid: email key is absent
const user2: UserProfile = {
id: '456',
name: 'Bob',
email: 'bob@example.com',
} // Valid: email key exists with string value
const user3: UserProfile = {
id: '789',
name: 'Charlie',
email: undefined,
} // Valid: email key exists with `undefined` value
Value Optional
The key must exist in the object, but in its value definition we allow undefined
as a valid value.
// --------------------------------------
// Type definition
// --------------------------------------
type Product = {
id: string
name: string
description: string | undefined // `description` key must exist, value can be string or undefined
}
// --------------------------------------
// Examples
// --------------------------------------
const product1: Product = {
id: 'A01',
name: 'Widget',
description: 'A fantastic widget.',
} // Valid
const product2: Product = {
id: 'A02',
name: 'Gadget',
description: undefined,
} // Valid: description key exists with `undefined` value
// @ts-expect-error - Property 'description' is missing in type
const product3: Product = {
id: 'A03',
name: 'Doodad',
} // Invalid: description key is missing
Which one to use?
Personally I always try to use value optional when possible, with null
values for keys without data.
Why? Because this makes sure that the final structure is always the same, so when the developer checks it, he gets the hint that there is a description
field to be used, it simply has no data yet.
2. Transform Types
TypeScript provides powerful built-in utility types to transform existing types, allowing for precise type definitions without duplication.
Pick<T, K>
and Omit<T, K>
Pick<T, K>
: creates a new type by selecting specific propertiesK
from typeT
.Omit<T, K>
: creates a new type by omitting specific propertiesK
from typeT
.
This is super useful for example to extract only-back properties from a inherited ORM type. For example, password
, updatedAt
, isActive
may be fields of a UserDto
that we don’t want to expose in the API, but we need to use them internally. So this allows us to create a white-list or a black-list of properties to use in our code, defining new types inherited from the original type, for each use case.
type User = {
id: string
name: string
email: string
createdAt: Date
updatedAt: Date
}
// Create a type with only 'id' and 'name'
type UserSummary = Pick<User, 'id' | 'name'>
// UserSummary = { id: string; name: string; }
// Create a type excluding 'createdAt' and 'updatedAt'
type UserInput = Omit<User, 'createdAt' | 'updatedAt'>
// UserInput = { id: string; name: string; email: string; }
Partial<T>
and Required<T>
TS also offers utilities to manipulate the optionality of properties.
Partial<T>
: makes all properties of typeT
optional.Required<T>
: makes all properties of typeT
required.
This is very useful for example when defining a PATCH
endpoint, where you may pass any prop (1 or all) of a resource to update it, but you don’t want to define a new type for each possible combination of properties. Instead, you can use Partial<T>
to make all properties optional.
type User = {
id: string
name: string
email: string
createdAt: Date
updatedAt: Date
}
// Create a type with all properties optional
type UserUpdate = Partial<User>
// UserUpdate = { id?: string; name?: string; email?: string; createdAt?: Date; updatedAt?: Date; }
And Required<T>
, while less common, when some data you are dealing with might have optional fields (perhaps an external API response), but a specific part of your system needs all properties to be present to work correctly. This use case has been generally replaced with a library like zod, but it can still be useful in some cases.
type SomeExternalData = {
something?: string
anotherThing?: number
}
// Create a type with all properties required
type RequiredData = Required<SomeExternalData>
// RequiredData = { something: string; anotherThing: number; }
Extract<T, U>
and Exclude<T, U>
Extract<T, U>
: Extracts fromT
all types that are assignable toU
.Exclude<T, U>
: Excludes fromT
all types that are assignable toU
.
This is used when working with union types. It’s easy to understand with an example:
type EventType = 'click' | 'submit' | 'hover' | 'scroll' | number | boolean
// Extract only string literal types
type StringEvents = Extract<EventType, string>
// StringEvents = 'click' | 'submit' | 'hover' | 'scroll'
// Exclude numbers and booleans
type NonNumericBooleanEvents = Exclude<EventType, number | boolean>
// NonNumericBooleanEvents = 'click' | 'submit' | 'hover' | 'scroll'
Or, with a more complex structure that might be used in a real-world scenario:
type NotificationUnionType =
| { type: 'email'; email: string; subject: string }
| { type: 'sms'; phoneNumber: string; message: string }
| { type: 'push'; deviceToken: string; title: string; body: string }
// Extract only email notifications
type EmailNotification = Extract<NotificationUnionType, { type: 'email' }>
// EmailNotification = { type: 'email'; email: string; subject: string }
type NonEmailNotification = Exclude<NotificationUnionType, { type: 'email' }>
// NonEmailNotification = { type: 'sms'; ... } | { type: 'push'; ... }
3. Loose Autocomplete (string & {})
This is a trick that once found, has been introduced in most libraries. Basically it allows to keep autocomplete in a non-strict list of strings. Again, obvious with an example:
Imagine that we accept any string as a valid input, BUT we want to offer some examples to the method’s consumer with the autocomplete.
type Something = 'a' | 'b' | 'c' | string
// type `string`
This type would collapse to a simple string
, because TS considers that string
is a supertype of 'a' | 'b' | 'c'
and therefore it covers all possible values. But it makes us lose the autocomplete of 'a' | 'b' | 'c'
.
To keep the autocomplete, we can use the trick of intersecting with an empty object, in one of these ways:
type Something = ('a' | 'b' | 'c') & {}
type Something = 'a' | 'b' | 'c' | (string & {})
This will keep allowing us to pass any string, but also will provide the autocomplete for the specific values in our type definition.
How does this work? The
& {}
creates an intersection with an empty object type. Sincestring
is not assignable to{}
(and vice versa) in a way that allows it to fully absorb the literal types, TypeScript preserves the union for autocomplete purposes while still allowing any string!
4. Discriminated Unions
Discriminated unions are a powerful way to model complex types that can take on multiple forms. They use a common property (the “discriminator”) to differentiate between the different types in the union. A typical example may be a EventType
or NotificationType
that can be one of several specific types, each with its own properties.
The idea here is to have a central type where we define the different options, and then generate the union type from that.
// here we define our 3 actions, each has its own properties
type Actions = {
login: {
username: string
password: string
}
logout: {
reason: string
}
update: {
id: string
data: unknown
}
}
export type ActionDiscriminatedUnionType = {
// Prettify<T> is a custom trick type! check in the next section
// it is optional, this would work without it too
[K in keyof Actions]: Prettify<
{
type: K // The discriminant property
} & Actions[K] // Spread the props of the specific action type
>
}[keyof Actions]
/*
ActionDiscriminatedUnionType will resolve to type union:
{
type: "login";
username: string;
password: string;
} | {
type: "logout";
reason: string;
} | {
type: "update";
id: string;
data: unknown;
}
*/
// Example usage:
function handleAction(action: ActionDiscriminatedUnionType) {
switch (action.type) {
case 'login':
console.log('Login attempt:', action.username)
break
case 'logout':
console.log('Logout reason:', action.reason)
break
case 'update':
console.log('Updating ID:', action.id, 'with data:', action.data)
break
default:
// TypeScript ensures all cases are handled if you use `never`
// const _exhaustiveCheck: never = action;
break
}
}
// All derived types from the original Actions type
type LoginAction = ActionDiscriminatedUnionType extends infer U ? (U extends { type: 'login' } ? U : never) : never
// LoginAction = { type: "login"; username: string; password: string; }
The [K in keyof Actions]: ...
maps over each key in Actions
, creating a new type for each. The type: K
part is the magic that creates the discriminant property. Finally, [keyof Actions]
collects all these mapped types into a single union. This pattern is incredibly clean and scalable.
5. Custom Utility Types
These are a few types I find myself reusing in almost every TypeScript project. They typically live in a common.types.ts
file.
export type Nullable<T> = T | null
export type Optional<T> = T | undefined
export type Maybe<T> = T | null | undefined
export type Empty = null | undefined
/**
* Forces TypeScript to simplify complex intersection or union types for better readability
* in IDE hover tooltips.
*/
export type Prettify<T> = {
[K in keyof T]: T[K]
} & {}
/**
* Constructs a type where all properties (including nested ones) of `T` are optional.
*/
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>
}
: T
The Prettify
type is particularly useful. When you have types derived from multiple intersections or complex manipulations (like our ActionDiscriminatedUnionType
), hovering over them in VS Code can show a very convoluted type signature. Prettify
forces TypeScript to resolve and display a flattened, more readable version, greatly improving the developer experience.
Conclusion and Reference
The types and patterns discussed here are invaluable tools for crafting robust, type-safe, and highly maintainable applications. By defining and enforcing data shapes, we can significantly reduce runtime errors and improve the developer experience.
This post is very much a blog-post-version extended by my own experiences, of concepts brilliantly explained by Matt Pocock (mattpocock.com) in his video 6 TypeScript tips to turn you into a WIZARD. I highly recommend checking out his work for more in-depth explanations and advanced techniques!