Sébastien TIMONER
Expert in web development and technical team management, I specialize in creating and optimizing high-performance digital solutions. With deep expertise in modern technologies like React.js, Node.js, TypeScript, Symfony, Docker, and FrankenPHP, I ensure the success of complex SaaS projects, from design to production, for companies across various sectors, at offroadLabs.
TypeScript utility types are predefined generic types that facilitate type transformations. They allow you to create new types from existing ones, for example by making certain properties optional or by extracting the return type of a function. This educational guide, in three levels (beginner, intermediate, advanced), presents all standard TypeScript utility types (such as Partial, Required, Readonly, Pick, Omit, Record, Exclude, Extract, NonNullable, Parameters, ReturnType, InstanceType, ThisType, etc.), with for each: an explanation of its usefulness, an example of use in TypeScript, then a comparison with an equivalent approach under Zod, a runtime validation schema library. We will also discuss the philosophical differences between TypeScript utility types (static type system) and Zod schemas (runtime validation).
To try the code snippets, you can initialize a minimal TypeScript project using Bun (an ultra-fast JavaScript runner that also serves as a package manager). Make sure you have Bun installed, then in an empty folder run:
shell
Bun will create a default package.json and tsconfig.json. You can then run a TypeScript file with bun run file.ts (Bun transpiles TypeScript on the fly).
We're ready to explore utility types!
TypeScript: The Partial<T> utility type constructs a type from an existing type T by making all its properties optional. In other words, each property of T becomes optional (adding a ?). This allows you to represent partial objects of this type. For example, if you have a User type with required fields, Partial<User> will allow you to create a version where these fields can be omitted:
typescript
Here, Partial<User> has the type { name?: string; age?: number }. This allows you, for example, to implement a partial update function without requiring all fields:
typescript
You call updateUser(existingUser, { age: 26 }) with a partial object – TypeScript statically guarantees that fieldsToUpdate contains only valid keys of User.
Equivalent with Zod: Zod offers an equivalent method to make an object schema partial. The .partial() method applied to a z.object schema makes all keys optional, similar to Partial in TypeScript. For example:
typescript
Here PartialUserSchema is a schema that validates objects containing possibly only a subset of keys (name and age become optional). You can check how it works:
typescript
Zod also allows you to make only certain properties optional by passing an object as a parameter (UserSchema.partial({ age: true }) only makes age optional). Note that .partial() only acts on the first level; Zod also provides .deepPartial() to recursively make nested fields optional.
TypeScript: The Required<T> utility type is the opposite of Partial: it creates a type from T by making all its properties required (non-optional). It's useful for "solidifying" a type whose properties were optional. For example:
typescript
In this example, Required<Settings> is equivalent to { theme: string; fontSize: number } – any omission of a property becomes a compilation error.
Equivalent with Zod: For a Zod schema, the equivalent is the .required() method, which transforms an object schema by making all its properties required (unlike .partial() which makes them optional). This method is especially useful if you first defined a partial schema and want to get the strict version. For example:
typescript
Here StrictUserSchema requires that name and age be present. You can also target specific properties: BaseUserSchema.required({ age: true }) only makes age required (and leaves name optional).
Note that Required<T> acts at compile time (it no longer exists in the emitted JavaScript code), while schema.required() of Zod acts at runtime by validating the presence of keys during parsing.
TypeScript: The Readonly<T> utility transforms a type T by making its properties read-only (immutable). Any attempt to reassign these properties will be reported as an error at compilation. For example:
typescript
Here, todo is of type Readonly<Todo> so its fields cannot be modified after initialization. This often corresponds to the use of Object.freeze in JavaScript, but TypeScript only handles this at the type level (it doesn't actually prevent mutation at runtime, it only warns at compile time).
Equivalent with Zod: Zod offers an interesting feature for immutability. The .readonly() method applied to a schema returns a new schema that, when parsing, calls Object.freeze() on the result, and whose static type is marked Readonly<...>. In other words, Zod can freeze the validated object and ensure that it corresponds to a readonly type. For example:
typescript
Here, FrozenUserSchema freezes the object output from parse. If you try to modify frozenUser, an exception will be thrown at runtime because the object is immutable. Thus, Zod goes further than TypeScript: where Readonly<T> is purely static (properties could be modified in JS if you bypass typing), the Zod .readonly() schema ensures real immutability at runtime in addition to providing the corresponding Readonly<...> type.
TypeScript: Pick<T, Keys> constructs a new type from T by keeping only certain properties specified by Keys (typically a union of string literals representing property names). It's useful for creating a more restricted type from an existing type. For example:
typescript
Here, TodoPreview only keeps title and completed from FullTodo. Any unlisted property (description for ex.) is excluded from the resulting type.
Equivalent with Zod: Zod object schemas also have a .pick() method that creates a new schema containing only the indicated keys, in a similar way:
typescript
You pass an object with the keys to keep set to true. TodoPreviewSchema will only validate objects with title and completed (other properties of FullTodo will be ignored by the parser by default). For example, TodoPreviewSchema.parse({ title: "Clean", completed: false, description: "..." }) will return { title: "...", completed: false } having removed description (because by default Zod ignores unspecified keys, behavior that can be adjusted with .strict() or .passthrough() as needed).
Similarly, Zod provides .omit() for the opposite effect.
TypeScript: Omit<T, Keys> creates a new type starting from T and removing certain properties (Keys). It's the complement of Pick (in fact Omit<T, K> is often implemented as Pick<T, Exclude<keyof T, K>>). Example:
typescript
Here TaskMeta only contains id and title. The properties done and dueDate have been excluded.
Equivalent with Zod: Zod's .omit() method produces an object schema without the indicated keys. Example:
typescript
TaskMetaSchema will validate objects containing only id and title. Any omitted key (done, dueDate) will be ignored if present in the input. Thus, Pick and Omit allow you to project a Zod schema onto a subset of fields just as their TypeScript equivalents do at the type level.
TypeScript: Record<Keys, Type> constructs an object type whose keys are of type Keys (typically a union of string literals or a more general string/number type) and values are of type Type. This allows you to describe, for example, an object indexed by identifiers. A classic example:
typescript
Here, Record<CatName, CatInfo> ensures that the cats object has exactly the keys "miffy", "boris", "mordred", each associated with a CatInfo. Record is very useful for typing objects used as dictionaries or maps.
Equivalent with Zod: Zod offers the z.record(keySchema, valueSchema) schema to validate objects of this kind. You can use it with a key schema (for example z.string() or a literal schema) and a value schema. Example:
typescript
Here, AgeMapSchema validates any key as long as it's a string (by default, Zod z.record without a first argument assumes string). If you have a finite set of keys, it's often better to use z.object({ ... }) with explicitly named keys or z.enum([...]) to constrain the keys. In fact, Zod allows a stricter key schema: you could define const CatNameSchema = z.enum(["miffy","boris","mordred"]) then do z.record(CatNameSchema, CatInfoSchema) – however, be aware that at runtime JavaScript object keys are always strings. Even a numeric key will be converted to a string at runtime (e.g., a key 1 becomes "1" in a JS object). Zod takes this into account and does not allow you to define a pure numeric key schema without transforming it to a string.
In summary, Record<Keys, Type> (TypeScript) and z.record(keySchema, valueSchema) (Zod) both allow you to type dictionaries. The former acts at the type system level, the latter at runtime to validate the object's structure.
TypeScript: Exclude<U, E> creates a type by removing from the union U all members that are assignable to type E. In other words, you exclude certain types from a union. For example:
typescript
Here, Exclude<Role, "admin"> removes the literal "admin" from the union, leaving only "user" | "guest". Another example: Exclude<string | number | boolean, string> would give number | boolean (removing everything assignable to string). It's useful for refining a union type by eliminating undesirable cases.
Equivalent with Zod: To restrict the possibilities of a union at runtime, you typically define a schema that doesn't include the cases to exclude. For example, if you have a RoleSchema = z.enum(["admin","user","guest"]), you can create a subset by not including "admin". Zod provides for enumerations the `.exclude([…]) method that generates a new enumeration schema without the specified values. For example:
typescript
This NonAdminSchema corresponds exactly to the Exclude<Role, "admin"> type but also ensures validation at runtime. Under the hood, RoleSchema.exclude(["admin"]) simply removes "admin" from the list of allowed literals. Similarly, Zod offers .extract([...]) to perform the complementary operation.
TypeScript: Extract<T, U> creates a type by keeping from T only members assignable to U. It's roughly the opposite of Exclude. For example:
typescript
Another example: Extract<"a" | "b" | "c", "a" | "f"> will give "a" (because only "a" is common to both). This utility type is therefore used to filter a union to extract only a certain subtype.
Equivalent with Zod: As with Exclude, Zod allows you to obtain at runtime a schema corresponding to a subset of an enumeration via .extract([...]). Let's take the example of roles again: to extract only, say, the limited roles "user" and "guest" from a complete RoleSchema:
typescript
Here LimitedRolesSchema corresponds to the type Extract<Role, "user" | "guest">. In practice, you could get the same result by directly defining z.enum(["user","guest"]), but .extract is convenient for deriving a schema from an existing enum. Note that .exclude and .extract of Zod apply to enumeration schemas (z.enum or z.nativeEnum), and not to arbitrary unions of complex types. For the latter, you generally manually construct the desired new union schema.
TypeScript: NonNullable<T> constructs a type by excluding null and undefined from T. It's common in TypeScript to have union types including these "nullish" values and to want a type that is purged of them. For example:
typescript
Here, DefinitelyString only contains string (neither null nor undefined). This utility type is often used in combination with presence conditions (if (value != null) { ... }) to help the compiler refine types.
Equivalent with Zod: With Zod, by default schemas don't accept undefined or null unless you specify it. In other words, a z.string() schema will only accept a string, not undefined. To allow these values, you explicitly use z.string().optional() (which allows undefined) or z.string().nullable() (which allows null) or even .nullish() (which allows either). Thus, obtaining the equivalent of a NonNullable type simply means not including null/undefined in the schema. However, if you start from a more permissive schema and want to make it stricter, you can use a combination of techniques: for example, the .unwrap() method allows you to extract the underlying schema from an optional or nullable schema.
typescript
Here, OptionalName.unwrap() returns the original z.string() schema. This allows you to "remove" the optional or nullable character introduced previously. Another approach would be to use a custom refinement to reject null/undefined, but that would be redundant given the behavior of Zod schemas.
TypeScript: Parameters<T> extracts the parameter types of a function type T as a tuple. This is useful for working with function signatures, especially when you want to create a function that wraps another function. For example:
typescript
Here, Parameters<typeof greet> extracts the tuple [string, number] representing the parameters of greet. This allows safeGreet to accept exactly the same parameters as greet.
Equivalent with Zod: Zod provides z.function() to define function schemas, with methods to specify parameter types and return type. While there's no direct equivalent to Parameters<T>, you can define a function schema and extract its parameter types using TypeScript's own Parameters utility on the inferred type:
typescript
Here, GreetSchema defines a function schema that takes a string and a number and returns a string. The .implement() method creates a wrapper function that validates inputs and outputs according to the schema. We can extract the parameter types using Parameters<GreetFn>. This approach combines runtime validation with static typing.
TypeScript: ReturnType<T> extracts the return type of a function type T. This is useful when you want to work with the result of a function without having to explicitly redefine its type. For example:
typescript
Here, ReturnType<typeof createUser> extracts the object type returned by createUser. This allows us to define the User type without duplicating the structure.
Equivalent with Zod: As with Parameters, there's no direct Zod equivalent to ReturnType, but you can combine Zod's function schema with TypeScript's ReturnType utility:
typescript
Here, we define a function schema with CreateUserSchema, then extract its return type in two ways: using TypeScript's ReturnType on the inferred function type, or using Zod's .returnType() method which returns the schema for the return value. Both approaches give us the User type.
TypeScript: InstanceType<T> extracts the instance type of a constructor function type T. This is useful when you have a class reference and want to work with instances of that class. For example:
typescript
Here, InstanceType<typeof Point> gives us the type of a Point instance, which includes its properties and methods.
Equivalent with Zod: Zod doesn't have a direct equivalent for class instance types, as it focuses on data validation rather than class behavior. However, you can define a schema for the shape of class instances:
typescript
This approach defines a schema that matches the shape of a Point instance, but it doesn't capture the full class relationship. For more complex class validation, you might need to combine Zod with class-validator or similar libraries.
TypeScript utility types and Zod schemas both serve to ensure data reliability, but at different levels: one at compile time, the other at runtime.
We can summarize it this way:
In terms of philosophy, utility types express transformations or constraints at the type system level (they help the developer, having no direct impact on the emitted code), while Zod provides contracts at the runtime level (they allow concrete implementation of data validations and transformations at execution). Both approaches address different but related needs: maintaining code robustness. Moreover, Zod is designed to integrate harmoniously with TypeScript: it automatically infers static types from your schemas, avoiding definition duplication. This means you can often define a Zod schema (for validation) and use z.infer to derive the corresponding static TypeScript type, instead of defining a TypeScript interface and a separate schema – thus having a single source of truth for your data types.
In conclusion, knowing TypeScript utility types allows you to leverage the type system to write safe and expressive code, while mastering Zod gives you the tools to enforce these type contracts at runtime. Used together, they significantly strengthen the reliability of your TypeScript applications: TypeScript protects you from programming errors, and Zod protects your application from unexpected data from the outside world. Create your types, validate your data, and code with peace of mind 🎉!