Advanced TypeScript Types: Things C# Can’t Do
For .NET engineers who know: C# generics, interfaces, abstract classes, LINQ, pattern matching You’ll learn: TypeScript’s advanced type system features — union types, conditional types, mapped types, discriminated unions, and more — with honest assessments of when to use them and when they are overkill Time: 25-30 min read
The .NET Way (What You Already Know)
C#’s type system is rich but nominally typed: a Dog is a Dog because it was declared class Dog, not because it happens to have a Bark() method. Generics give you parameterized types (List<T>, Task<T>, IRepository<T>), interfaces define contracts, and abstract classes share implementation. You use where T : class and where T : IEntity to constrain generic parameters.
The C# type system is deliberately conservative. It prioritizes correctness and expressibility for object-oriented patterns. What it does not offer natively: the ability to express “this value is one of these specific string literals,” the ability to derive a new type from an existing one by picking a subset of its properties, or the ability to write a type that resolves differently depending on what type you pass in.
For most enterprise .NET code, these constraints are invisible. The pattern matching added in C# 7-9 addressed some gaps. But there are entire categories of type-level computation that C# cannot do at all.
TypeScript’s type system can. And in real-world TypeScript projects, these features appear constantly — in library code you will consume, in the patterns your team already uses, and in the errors you will debug when you get them wrong.
This article covers the advanced features. Approach them as a toolkit, not a showcase. Each one solves a specific problem. Each one has a failure mode where it adds complexity with no real benefit.
The TypeScript Way
Union Types: “This or That”
A union type says a value can be one of several types:
// TypeScript
type StringOrNumber = string | number;
function format(value: StringOrNumber): string {
return String(value);
}
format("hello"); // ok
format(42); // ok
format(true); // error: Argument of type 'boolean' is not assignable
Closest C# analogy: None that’s clean. You might reach for a common base class, an object parameter, or an overloaded method. The closest structural approximation is a discriminated union via a sealed class hierarchy — but that requires multiple class declarations and a runtime dispatch pattern. TypeScript’s union type is a first-class, zero-runtime-cost construct.
Practical use case: API responses often have different shapes depending on success or failure.
// Without union types — you'd need a class hierarchy or object with optional fields
type ApiResponse<T> =
| { status: "success"; data: T }
| { status: "error"; code: number; message: string };
function handleResponse(response: ApiResponse<User>): void {
if (response.status === "success") {
console.log(response.data.name); // TypeScript knows .data exists here
} else {
console.log(response.message); // TypeScript knows .message exists here
}
}
When not to use it: If your “union” grows to five or more members and you are constantly checking the discriminant, you may actually want a class hierarchy or a dedicated state machine. Union types with many arms are hard to extend — every check site needs updating.
Intersection Types: “This and That”
Where union types are “or,” intersection types are “and”:
// TypeScript
type Named = { name: string };
type Aged = { age: number };
type Person = Named & Aged;
const person: Person = { name: "Chris", age: 40 }; // must satisfy both
Closest C# analogy: Implementing multiple interfaces. class Person : INamed, IAged. The difference: intersection types work on object literal shapes without class declarations. You compose types structurally, not nominally.
Practical use case: Merging types in utility functions — for example, a generic “with ID” wrapper:
type WithId<T> = T & { id: string };
type UserDto = { name: string; email: string };
type UserRecord = WithId<UserDto>;
// { name: string; email: string; id: string }
function save<T>(entity: T): WithId<T> {
return { ...entity, id: crypto.randomUUID() };
}
When not to use it: Intersecting two types that have conflicting properties for the same key produces never for that property, which silently breaks things:
type A = { id: string };
type B = { id: number };
type Broken = A & B; // { id: never } — no value can satisfy this
Intersections work best when the merged types have non-overlapping properties.
Literal Types: Exact Values as Types
In C#, a string can hold any string. In TypeScript, you can create a type that only accepts specific string (or number, or boolean) values:
// TypeScript
type Direction = "north" | "south" | "east" | "west";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type Port = 80 | 443 | 8080;
function move(direction: Direction): void {
// direction is guaranteed to be one of the four values
}
move("north"); // ok
move("up"); // error: Argument of type '"up"' is not assignable
Closest C# analogy: enum, but better in two ways. First, string literal types carry their value as their identity — you do not need to convert between enum and string for serialization. Second, literal types participate in the full type system and can be used anywhere a type can appear.
C# comparison:
// C# — you need an enum and then string conversion for JSON, API contracts, etc.
public enum Direction { North, South, East, West }
// TypeScript — the string literal IS the value
type Direction = "north" | "south" | "east" | "west";
const d: Direction = "north"; // serializes directly
Practical use case: Strongly typed event names, CSS property values, configuration keys — anywhere you have a fixed, small set of valid string values.
When not to use it: If the set of valid values is dynamic, comes from a database, or needs to be extended without code changes. In those cases, a validated string at runtime (via Zod — see Article 2.3) is the right tool, not a compile-time literal type.
Template Literal Types: Type-Level String Interpolation
TypeScript can construct string literal types by combining other string literals — at the type level, not at runtime:
// TypeScript
type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"
type CSSProperty = "margin" | "padding";
type CSSDirection = "Top" | "Bottom" | "Left" | "Right";
type CSSFullProperty = `${CSSProperty}${CSSDirection}`;
// "marginTop" | "marginBottom" | ... | "paddingRight" (8 combinations)
C# analogy: None. C# has no mechanism to compute string types at compile time.
Practical use case: Typed event maps, typed route parameters, typed CSS-in-TS:
// Typed route parameter extraction
type ExtractParams<Route extends string> =
Route extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: Route extends `${string}:${infer Param}`
? Param
: never;
type UserRouteParams = ExtractParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"
// Practical typed router:
function buildRoute<T extends string>(
template: T,
params: Record<ExtractParams<T>, string>
): string {
let result: string = template;
for (const [key, value] of Object.entries(params)) {
result = result.replace(`:${key}`, value as string);
}
return result;
}
const url = buildRoute("/users/:userId/posts/:postId", {
userId: "abc",
postId: "123",
});
// TypeScript enforces that you provide exactly userId and postId
When not to use it: Template literal types get unwieldy fast. If the combinations produce more than a dozen string values, they become expensive for the compiler and hard to read in error messages. The router example above is near the limit of practical complexity.
Discriminated Unions: The Functional State Pattern
A discriminated union is a union of types that share a common “discriminant” field — a literal-typed property that uniquely identifies which member of the union you have. This is the TypeScript equivalent of a sealed class hierarchy in C#, but it composes more cleanly.
// TypeScript
type LoadingState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: Error };
// The closest C# equivalent — much more ceremony
public abstract record LoadingState;
public record Idle : LoadingState;
public record Loading : LoadingState;
public record Success(IEnumerable<User> Data) : LoadingState;
public record Error(Exception Exception) : LoadingState;
// Pattern matching in C# 9+
var result = state switch {
Success s => s.Data,
Error e => throw e.Exception,
_ => throw new InvalidOperationException()
};
TypeScript’s version — with switch statements or conditional chains — is more compact, requires no base type, and the compiler narrows the type automatically based on the discriminant check:
// TypeScript narrowing via discriminated union
function render(state: LoadingState): string {
switch (state.status) {
case "idle":
return "Waiting...";
case "loading":
return "Loading...";
case "success":
return state.data.map((u) => u.name).join(", "); // data is typed here
case "error":
return `Error: ${state.error.message}`; // error is typed here
}
}
The compiler also enforces exhaustiveness when you configure it correctly. Add a default branch that assigns to never:
function assertNever(value: never): never {
throw new Error(`Unhandled discriminant: ${JSON.stringify(value)}`);
}
function render(state: LoadingState): string {
switch (state.status) {
case "idle": return "Waiting...";
case "loading": return "Loading...";
case "success": return state.data.map((u) => u.name).join(", ");
case "error": return `Error: ${state.error.message}`;
default: return assertNever(state); // compiler error if a case is missing
}
}
Practical use case: UI state machines, API response shapes, command/event types in CQRS-like patterns, WebSocket message types.
When not to use it: Discriminated unions do not support inheritance-like composition well. If you need to add behavior to each variant (methods, not just data), a class hierarchy remains more ergonomic.
Conditional Types: Type-Level If/Else
Conditional types apply a conditional at the type level:
// T extends U ? X : Y
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true — "hello" extends string
On its own this looks academic. In combination with generics, it enables patterns that are genuinely useful:
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer R> ? R : T;
// (This is now built into TypeScript, but understanding it matters)
type UserData = Awaited<Promise<User>>; // User
type NumberData = Awaited<number>; // number
// NonNullable removes null and undefined from a type
type NonNullable<T> = T extends null | undefined ? never : T;
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string
C# analogy: The closest C# mechanism is generic constraints (where T : class) combined with overloaded methods — but this enforces constraints rather than computing output types. C# has no way to say “if T is X, return Y, otherwise return Z” in the type system.
Practical use case — typed API client method overloads:
// Different return types based on whether pagination is requested
type PaginatedResponse<T> = { data: T[]; total: number; page: number };
type QueryResult<T, Paginated extends boolean> =
Paginated extends true ? PaginatedResponse<T> : T[];
async function query<T, P extends boolean = false>(
endpoint: string,
paginate?: P
): Promise<QueryResult<T, P>> {
// implementation
throw new Error("not implemented");
}
const users = await query<User>("/users"); // User[]
const paged = await query<User>("/users", true); // PaginatedResponse<User>
When not to use it: Conditional types can nest multiple levels deep and become essentially unreadable. If you find yourself writing T extends A ? (T extends B ? X : Y) : Z, step back and ask whether two separate functions or a union return type solves the problem more clearly. They are powerful; they are also the most common source of “I wrote this, and now I cannot debug it” type-system problems.
Mapped Types: Transform Every Key in a Type
Mapped types iterate over the keys of a type and produce a new type by applying a transformation:
// The syntax: { [K in keyof T]: transformation }
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Partial<T> = { [K in keyof T]?: T[K] };
type Required<T> = { [K in keyof T]-?: T[K] }; // -? removes optional
type Nullable<T> = { [K in keyof T]: T[K] | null };
These are all built into TypeScript, but understanding that they are derived — not primitive — changes how you think about the type system.
The built-in utility types you will use constantly:
type User = {
id: string;
name: string;
email: string;
role: "admin" | "user";
createdAt: Date;
};
// Partial<T> — all fields optional (PATCH request body)
type UpdateUserDto = Partial<User>;
// { id?: string; name?: string; email?: string; ... }
// Pick<T, K> — only keep specified keys
type UserSummary = Pick<User, "id" | "name">;
// { id: string; name: string }
// Omit<T, K> — remove specified keys
type CreateUserDto = Omit<User, "id" | "createdAt">;
// { name: string; email: string; role: "admin" | "user" }
// Record<K, V> — typed dictionary
type UsersByRole = Record<"admin" | "user", User[]>;
// { admin: User[]; user: User[] }
// Required<T> — remove all optionals
type FullUser = Required<Partial<User>>;
// Same as User — all fields mandatory again
C# comparison:
// C# — requires separate class declarations for each shape
public class User { public string Id; public string Name; public string Email; }
public class UpdateUserDto { public string? Name; public string? Email; } // manual
public class CreateUserDto { public string Name; public string Email; } // manual
public class UserSummary { public string Id; public string Name; } // manual
// TypeScript — derived from User automatically
type UpdateUserDto = Partial<Omit<User, "id" | "createdAt">>;
type CreateUserDto = Omit<User, "id" | "createdAt">;
type UserSummary = Pick<User, "id" | "name">;
If you change the User type in TypeScript, all three derived types update automatically. In C#, you update four separate classes manually.
Custom mapped types — a real example:
// Convert every method in a type to return a Promise version
type Promisify<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? (...args: A) => Promise<R>
: T[K];
};
interface SyncRepository {
findById(id: string): User;
save(user: User): void;
delete(id: string): boolean;
}
type AsyncRepository = Promisify<SyncRepository>;
// {
// findById(id: string): Promise<User>;
// save(user: User): Promise<void>;
// delete(id: string): Promise<boolean>;
// }
When not to use it: Do not create custom mapped types for one-off situations. If you only need a specific shape once, just declare that type explicitly. Mapped types earn their complexity when you need to derive several types from one source of truth, or when you are building library/utility code that will be reused across many types.
The infer Keyword: Extract Type Components
infer appears inside conditional types and lets you capture a part of a matched type into a named type variable:
// Extract the return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type GetUser = () => Promise<User>;
type UserResult = ReturnType<GetUser>; // Promise<User>
// Extract the element type of an array
type ElementType<T> = T extends (infer E)[] ? E : never;
type Names = ElementType<string[]>; // string
type Ids = ElementType<number[]>; // number
// Extract the resolved value from a Promise
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;
type ResolvedUser = UnwrapPromise<Promise<User>>; // User
type PassThrough = UnwrapPromise<string>; // string
C# analogy: Reflection-based type extraction at runtime — but TypeScript’s infer operates entirely at compile time with zero runtime cost.
Practical use case — extracting function parameter types for wrapper functions:
// NestJS interceptor pattern: wrap any async service method with retry logic
type AsyncFn = (...args: any[]) => Promise<any>;
type RetryWrapper<T extends AsyncFn> = (
...args: Parameters<T>
) => Promise<Awaited<ReturnType<T>>>;
function withRetry<T extends AsyncFn>(fn: T, maxAttempts = 3): RetryWrapper<T> {
return async (...args) => {
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn(...args);
} catch (err) {
lastError = err;
}
}
throw lastError;
};
}
async function fetchUser(id: string): Promise<User> {
// implementation
throw new Error("not implemented");
}
const robustFetchUser = withRetry(fetchUser); // (id: string) => Promise<User>
// Type fully preserved through the wrapper
When not to use it: infer is exclusively for advanced utility types and library-level code. Application code should rarely need it directly. If you find yourself reaching for infer in a controller or service, you are almost certainly over-engineering the solution.
The satisfies Operator: Validate Without Widening
Added in TypeScript 4.9, satisfies validates that a value conforms to a type without changing the inferred type of the value. This is the distinction between “I know this fits the type” and “I want TypeScript to infer the specific type but also confirm it fits.”
type ColorMap = Record<string, string | [number, number, number]>;
// Without satisfies — TypeScript widens to the annotation type
const colors: ColorMap = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
};
// colors.red has type `string | [number, number, number]`
// TypeScript loses the knowledge that red is specifically a tuple
colors.red.toUpperCase(); // error — TS thinks it might be a tuple
// With satisfies — validation without widening
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies ColorMap;
// palette.red has type [number, number, number] — the specific inferred type
// palette.green has type string — specific inferred type
palette.red.map((c) => c * 2); // ok — TypeScript knows it's a tuple
palette.green.toUpperCase(); // ok — TypeScript knows it's a string
C# analogy: None direct. The closest concept is an implicit type conversion that validates the target interface without losing the concrete type, but C# does not work this way — you either declare the variable as the interface type (and lose access to concrete members) or the concrete type (and lose the validation).
Practical use case — NestJS configuration objects:
// NestJS module options often have broad types
type DbConfig = {
host: string;
port: number;
ssl?: boolean;
poolSize?: number;
};
// Using satisfies: TypeScript validates the config AND keeps the specific types
const dbConfig = {
host: "localhost",
port: 5432,
ssl: false,
poolSize: 10,
} satisfies DbConfig;
// Without satisfies, if you annotated as DbConfig:
// dbConfig.host would be `string` (fine, but...)
// dbConfig.port would be `number` (fine)
// But if you had a literal type context, satisfies preserves it
// More useful example with a route config
type RouteConfig = {
method: "GET" | "POST" | "PUT" | "DELETE";
path: string;
requiresAuth: boolean;
};
const routes = {
getUsers: { method: "GET", path: "/users", requiresAuth: true },
createUser: { method: "POST", path: "/users", requiresAuth: true },
} satisfies Record<string, RouteConfig>;
// routes.getUsers.method is "GET" — not just "GET" | "POST" | "PUT" | "DELETE"
// TypeScript validates the shape but preserves the literal types
When not to use it: satisfies solves a specific problem: you want type validation without losing the specific inferred type. If you do not need access to the specific inferred type afterward (i.e., the widened annotation type is fine), a plain type annotation is simpler and clearer.
Type Narrowing and Type Guards: Recover Specificity from Broad Types
Type narrowing is how TypeScript automatically refines a broad type to a more specific one based on runtime checks. It is built into the language and powers discriminated unions, but it also works with typeof, instanceof, in, and truthiness checks:
function process(input: string | number | null): string {
if (input === null) {
return "empty"; // narrowed to null
}
if (typeof input === "string") {
return input.toUpperCase(); // narrowed to string
}
return input.toFixed(2); // narrowed to number — TypeScript deduced this
}
User-defined type guards let you write a function that narrows types in ways the compiler cannot infer automatically. The return type value is SomeType is the key:
// TypeScript type guard
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
typeof (value as any).id === "string" &&
typeof (value as any).name === "string"
);
}
function processApiResponse(raw: unknown): string {
if (isUser(raw)) {
return raw.name; // TypeScript now knows raw is User
}
return "unknown entity";
}
C# comparison:
// C# pattern matching narrowing
object input = GetValue();
if (input is string s) {
Console.WriteLine(s.ToUpper()); // s is string here
}
if (input is User u) {
Console.WriteLine(u.Name); // u is User here
}
C# pattern matching is similar, but it relies on the CLR’s nominal type system. TypeScript’s structural narrowing is more flexible: you can narrow based on property shapes, not just declared types.
The in narrowing operator is particularly useful with discriminated unions or when working with object types:
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function makeNoise(animal: Cat | Dog): void {
if ("meow" in animal) {
animal.meow(); // narrowed to Cat
} else {
animal.bark(); // narrowed to Dog
}
}
Assertion functions are a stricter variant of type guards — they throw if the assertion fails rather than returning boolean:
function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new Error(`Expected User, got: ${JSON.stringify(value)}`);
}
}
function processFromApi(raw: unknown): void {
assertIsUser(raw); // throws if raw is not a User
console.log(raw.name); // TypeScript knows raw is User here
}
When not to use custom type guards: If you are in code that already uses Zod for validation (see Article 2.3), Zod’s .parse() and .safeParse() are both a type guard and a runtime validator combined — writing a manual type guard duplicates work Zod already does. Reserve manual type guards for situations where Zod is not present or would be overkill.
Key Differences
| Feature | C# | TypeScript |
|---|---|---|
| Union types | No equivalent (closest: sealed hierarchy) | First-class: A | B |
| Intersection types | Implementing multiple interfaces (nominal) | Structural merge: A & B |
| Literal types | enum (less flexible) | "north" | "south" |
| Template literal types | Not available | `on${Capitalize<T>}` |
| Conditional types | Not available | T extends U ? X : Y |
| Mapped types | Manual class declarations | { [K in keyof T]: ... } |
| Type narrowing | Pattern matching (is, switch expressions) | typeof, in, discriminant checks |
| User-defined type guards | is patterns (limited) | value is T return type |
infer keyword | Not available | Extract components from matched types |
satisfies operator | Not available | Validate without widening inference |
| Runtime type erasure | Types exist at runtime (CLR) | All types erased at runtime (JS) |
| Nominal vs. structural | Nominal (types must match by name) | Structural (types match by shape) |
Gotchas for .NET Engineers
Gotcha 1: TypeScript Types Do Not Exist at Runtime
This is the foundational error .NET engineers make. In C#, if you have a User variable, the CLR knows it is a User. You can use GetType(), is, as, and reflection. The type is real at runtime.
In TypeScript, types are erased during compilation. At runtime, you have JavaScript objects. There is no typeof user === User in TypeScript — typeof only returns "object", "string", "number", "boolean", "function", "symbol", or "undefined".
// This is a common mistake
type User = { id: string; name: string };
const response: User = await fetch("/api/users/1").then((r) => r.json());
// TypeScript is satisfied. But if the API returns { id: 123, name: null },
// TypeScript does NOT catch this. The assertion in the type annotation is a lie
// the compiler accepts without question.
// Correct approach: runtime validation with Zod
import { z } from "zod";
const UserSchema = z.object({ id: z.string(), name: z.string() });
const response = UserSchema.parse(await fetch("/api/users/1").then((r) => r.json()));
// Now throws if the shape is wrong — same guarantees as C# deserialization with validation
Every advanced type feature in this article operates at compile time only. They give you better intellisense and catch more errors during development. They provide zero protection against malformed data at runtime.
Gotcha 2: any Silently Disables the Type System, Including Your Advanced Types
When a value is typed as any, TypeScript stops checking it entirely. Advanced types have no effect when any appears in the chain:
type StrictUser = {
id: string;
name: string;
};
function getUser(): any {
return { id: 123, name: null }; // wrong types — but any masks it
}
const user: StrictUser = getUser(); // TypeScript allows this — no error
console.log(user.name.toUpperCase()); // runtime error: Cannot read properties of null
In C#, the equivalent would be returning object and casting — and you have been trained to treat that as a code smell. Apply the same instinct to any in TypeScript. unknown is the safe alternative: it requires you to narrow before you use it.
function getUser(): unknown {
return { id: 123, name: null };
}
const user = getUser();
user.name; // error: Object is of type 'unknown'
// Must narrow first:
if (isUser(user)) {
user.name; // ok — narrowed to User
}
any also infects downstream types. A conditional type applied to any resolves to any. A mapped type over any resolves to any. The advanced features in this article do not protect you from any.
Gotcha 3: Complex Type Errors Are Hard to Interpret
C# type errors are usually specific: “Cannot implicitly convert type ‘int’ to ‘string’.” TypeScript type errors on complex generic types can be paragraphs long:
Type '{ id: string; name: string; }' is not assignable to type 'Partial<Omit<Required<User>, "createdAt" | "updatedAt">> & WithId<Pick<User, "role">>'.
Type '{ id: string; name: string; }' is not assignable to type 'WithId<Pick<User, "role">>'.
Property 'role' is missing in type '{ id: string; name: string; }' but required in type 'Pick<User, "role">'.
This is not TypeScript failing — it is correct. But it is hard to read. Two practices help:
First, decompose complex types into named intermediate types. Instead of one deeply nested type expression, give each layer a name. The error messages then reference the named type, which is easier to locate.
// Hard to debug when something goes wrong:
type UserPatchRequest = Partial<Omit<Required<User>, "id" | "createdAt">> & { _version: number };
// Easier to debug:
type MutableUserFields = Omit<User, "id" | "createdAt">;
type RequiredMutableFields = Required<MutableUserFields>;
type OptionalMutableFields = Partial<RequiredMutableFields>;
type UserPatchRequest = OptionalMutableFields & { _version: number };
Second, use the TypeScript playground or VS Code’s “Go to Type Definition” to inspect what a complex type resolves to. Hover over the type name — VS Code will show the fully expanded shape. This is the equivalent of evaluating a LINQ expression in the debugger to see the actual SQL.
Gotcha 4: Discriminated Unions Do Not Protect Against Missing Cases Without Exhaustiveness Checking
A switch statement over a discriminated union that does not cover all members compiles without error by default:
type Status = "pending" | "active" | "suspended" | "deleted";
function getLabel(status: Status): string {
switch (status) {
case "pending": return "Pending";
case "active": return "Active";
// Missing: "suspended" and "deleted"
// TypeScript does not complain — the function just returns undefined at runtime
}
}
Add explicit exhaustiveness checking to catch this at compile time:
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function getLabel(status: Status): string {
switch (status) {
case "pending": return "Pending";
case "active": return "Active";
case "suspended": return "Suspended";
case "deleted": return "Deleted";
default: return assertNever(status); // compile error if a case is missing
}
}
Now if you add "archived" to Status, every assertNever call becomes a compile error. This is the TypeScript equivalent of C# exhaustive pattern matching with switch expressions that require all cases.
Gotcha 5: Partial<T> Does Not Mean “Optional Fields for Updates” — It Means “All Fields Optional”
A common pattern from C# PATCH endpoints is to accept a DTO where all fields are optional and apply only the provided ones. Partial<T> looks like the right tool. It is close, but it accepts {} as a valid value — an update with no fields set:
type UserUpdateDto = Partial<User>;
function updateUser(id: string, dto: UserUpdateDto): Promise<User> {
// dto could be {}, which is technically valid but useless
// dto could also contain undefined values explicitly
throw new Error("not implemented");
}
updateUser("abc", {}); // TypeScript allows this — is it intentional?
If you need “at least one field must be provided,” you need a more precise type:
// RequireAtLeastOne: at least one key from T must be present
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
Pick<T, Exclude<keyof T, Keys>> &
{ [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>> }[Keys];
type NonEmptyUserUpdate = RequireAtLeastOne<Partial<Omit<User, "id" | "createdAt">>>;
This type is complex enough that it belongs in a shared utility file with a comment explaining it. In practice, many teams skip this precision and add a runtime check instead — which is also defensible.
Hands-On Exercise
This exercise uses the types from a real NestJS + Prisma project structure.
Setup: You have a User model from Prisma:
// Generated by Prisma — do not modify
type User = {
id: string;
email: string;
name: string;
role: "admin" | "user" | "guest";
isActive: boolean;
createdAt: Date;
updatedAt: Date;
};
Tasks:
-
Define a
CreateUserDtothat omitsid,createdAt,updatedAt, andisActive(which defaults totrueon creation). -
Define an
UpdateUserDtothat makes all remaining fields optional and excludesidand the date fields. Then write a type guard functionisNonEmptyUpdate(dto: UpdateUserDto): booleanthat returnstrueonly if at least one field is actually set (notundefined). -
Define a
UserSummarytype usingPickthat includes onlyid,name, androle. -
Define a
UserApiResponseas a discriminated union with these three shapes:- Success:
{ status: "success"; user: UserSummary } - Not found:
{ status: "not_found"; requestedId: string } - Forbidden:
{ status: "forbidden"; reason: string }
- Success:
-
Write a
handleUserResponse(response: UserApiResponse): stringfunction that returns a human-readable message for each case. Add exhaustiveness checking withassertNever. -
Define a
UserEventMaptype using template literal types andRecord, where the keys are"user:created","user:updated","user:deleted", and each value is a function that receives the relevant data type.
Expected output: After completing the exercise, open VS Code and hover over UserApiResponse in the switch statement’s default case. It should show never — confirming exhaustiveness. Hover over each branch’s response.user or response.requestedId — TypeScript should show the narrowed type.
Quick Reference
Utility Types Cheat Sheet
| Utility Type | What It Produces | C# Equivalent |
|---|---|---|
Partial<T> | All properties optional | Manual DTO with ? properties |
Required<T> | All properties required | Manual DTO with non-null properties |
Readonly<T> | All properties readonly | IReadOnly<T> interface |
Pick<T, K> | Only keep keys K | Manual class with subset of fields |
Omit<T, K> | Remove keys K | Manual class without those fields |
Record<K, V> | Dictionary with typed keys and values | Dictionary<K, V> |
Exclude<T, U> | Remove U from union T | No equivalent |
Extract<T, U> | Keep only U from union T | No equivalent |
NonNullable<T> | Remove null and undefined | T with non-nullable reference type |
ReturnType<T> | Return type of function T | typeof + reflection |
Parameters<T> | Tuple of parameter types of function T | No equivalent |
Awaited<T> | Resolved type of a Promise | No equivalent |
InstanceType<T> | Instance type of a constructor | No equivalent |
Pattern Decision Guide
| Situation | Use |
|---|---|
| Value is one of several types | Union type A | B |
| Type must satisfy multiple contracts | Intersection type A & B |
| Field limited to specific string values | String literal union |
| Derive types from a string pattern | Template literal types |
| State machine or tagged variants | Discriminated union |
| Type varies based on input type | Conditional type |
| Derive new type from all keys of another | Mapped type |
| Extract component from matched type | infer keyword |
| Validate shape without widening | satisfies operator |
| Narrow broad type from runtime checks | Type narrowing / type guards |
When to Use Each Feature
| Feature | Use when… | Avoid when… |
|---|---|---|
| Union types | Multiple valid types for a value | More than ~5 arms without a discriminant |
| Intersection types | Composing non-overlapping shapes | Properties with the same key exist in both |
| Literal types | Small, fixed set of string/number values | Values come from a database or config |
| Template literal types | Constructing string type combinations | The combination count is very large |
| Discriminated unions | State machines, tagged variants | You need inheritance-like method sharing |
| Conditional types | Library/utility code, typed overloads | Application business logic |
| Mapped types | Multiple DTO variants from one source | One-off shape that only appears once |
infer | Library code, advanced generic utilities | Application controllers and services |
satisfies | Validating without losing literal type inference | Widened annotation type is acceptable |
| Type guards | Unknown input, API responses (without Zod) | Zod is already in use for the same boundary |
Further Reading
- TypeScript Handbook: Advanced Types — The official reference for everything covered in this article. Read the “Conditional Types,” “Mapped Types,” and “Template Literal Types” sections.
- TypeScript Utility Types Reference — Complete list of built-in utility types with examples.
- TypeScript 4.9 Release Notes:
satisfies— The original motivation and examples for thesatisfiesoperator, from the TypeScript team. - Type Challenges — A curated set of type-system problems ranging from warm-up to extreme. Working through the medium-level challenges is the most effective way to develop fluency with conditional types and
infer. Treat it as a kata repository, not a completionist exercise.