TypeScript Optional
The word "optional" in TypeScript optional code covers four different features that all
share one character: ?. Optional properties, optional parameters, optional chaining,
and optional tuple elements. They look related, and they are, but each one has its own
rules and its own way of surprising you.
This page walks through all of them, in the order you usually meet them.
What TypeScript optional properties really mean
A ? after a property name says the key may be missing.
interface UserProfile {
id: number
displayName: string
nickname?: string
}
const minimalProfile: UserProfile = { id: 1, displayName: 'Ada' }
const fullProfile: UserProfile = { id: 2, displayName: 'Grace', nickname: 'Amazing Grace' }Both assignments are valid. That is the write side of the story.
The read side is where people get tripped up. When you read nickname, its type is not
string. It is string | undefined, because the key might not be there.
function announce(profile: UserProfile): string {
const label = profile.nickname
if (label === undefined) {
return profile.displayName
}
return label
}You cannot skip that check. TypeScript will not let a possibly-missing value flow into a
slot that wants a string.
Optional is not the same as | undefined
It is tempting to treat nickname?: string and nickname: string | undefined as the
same thing. They are not. The ? makes the key optional. The union only makes the
value nullable — the key is still required.
interface ExplicitUndefined {
nickname: string | undefined
}
// const missingKey: ExplicitUndefined = {}
// ❌ Property 'nickname' is missing in type '{}'
const explicitKey: ExplicitUndefined = { nickname: undefined } // ✅So ? is the looser of the two. Use it when the key genuinely may not be present. Use
| undefined when callers must make a deliberate decision and say so.
exactOptionalPropertyTypes
By default, ? also lets you write undefined into the key explicitly. That is usually
harmless, but it blurs the line between "this field was never set" and "this field was
cleared" — which matters a lot for PATCH-style payloads.
interface PatchPayload {
nickname?: string
}
const clearIt: PatchPayload = { nickname: undefined }
// ✅ by default
// ❌ with "exactOptionalPropertyTypes": true in tsconfig.json —
// Type 'undefined' is not assignable to type 'string'Turn the flag on and ? means only "the key may be absent". If you want to allow an
explicit undefined after that, you have to spell it out as nickname?: string | undefined.
It is a strict flag worth enabling on new projects and worth being careful with on old ones.
Optional function parameters
The same ? marks a parameter as skippable at the call site.
function formatPrice(amount: number, currency?: string): string {
if (currency === undefined) {
return `${amount}`
}
return `${amount} ${currency}`
}
formatPrice(10)
formatPrice(10, 'EUR')Inside the function, currency is string | undefined, exactly like an optional
property. Same rule, same required check.
One structural constraint: an optional parameter cannot be followed by a required one.
// function badOrder(currency?: string, amount: number) {}
// ❌ A required parameter cannot follow an optional parameterThat is a positional-argument limitation, not a type-system one. If you find yourself fighting it, the answer is almost always a single options object instead of five positional parameters.
Optional versus a default value
A default value also makes the parameter skippable, but it changes the type inside the function body.
function formatWithDefault(amount: number, currency = 'EUR'): string {
return `${amount} ${currency}`
}Callers can still write formatWithDefault(10). But inside, currency is string, not
string | undefined — the default already filled the hole. If you have a sensible
fallback, reach for a default. It removes a check from every branch below it.
Optional methods
Interfaces can mark whole methods optional, in either of the two call-signature styles.
interface BuildPlugin {
name: string
setup?(): void
teardown?: () => void
}
declare const cachePlugin: BuildPlugin
cachePlugin.setup?.()
cachePlugin.teardown?.()Both forms mean the same thing to a caller: the method may not be there, so you cannot
call it unguarded. This is the shape almost every plugin and lifecycle-hook API lands on,
and ?.() is what makes consuming it bearable.
Optional properties in destructuring
Destructuring an optional property gives you the union, same as any other read. A default in the pattern closes it back up.
interface ConnectOptions {
host: string
port: number
secure?: boolean
}
function openSocket({ host, port, secure = false }: ConnectOptions): string {
return `${host}:${port} secure=${secure}`
}Inside openSocket, secure is boolean — the default already handled the missing case.
Drop the = false and it becomes boolean | undefined, and every use below needs a check.
Defaults in destructuring patterns fire on undefined only, exactly like ??, so a
stored false still comes through as false.
Optional chaining with ?.
Optional chaining reads a value that may not exist without blowing up at runtime.
interface Company {
address?: {
city: string
zip?: string
}
employees?: string[]
onHire?: (name: string) => void
}
declare const acme: Company
const cityName = acme.address?.city // string | undefined
const firstHire = acme.employees?.[0] // string | undefined
acme.onHire?.('Linus')Three forms, one operator. ?. for properties, ?.[] for index access, and ?.() for
calls. The call form is the one people forget, and it is the cleanest way to invoke an
optional callback.
The important behaviour is short-circuiting. If any link in the chain is null or
undefined, the whole expression stops there and evaluates to undefined. Nothing
further down the chain runs.
declare const globex: Company
const zipCode = globex.address?.zip?.trim()If address is missing, zip is never read and .trim() is never called. zipCode is
string | undefined.
?? is not ||
Optional values pair naturally with the nullish coalescing operator. Reaching for ||
instead is one of the most common bugs in this whole area.
interface DisplayOptions {
retries?: number
title?: string
}
declare const opts: DisplayOptions
const retriesOr = opts.retries || 3 // 0 silently becomes 3 ❌
const retriesNullish = opts.retries ?? 3 // 0 stays 0 ✅
const titleNullish = opts.title ?? 'Untitled'|| falls back on every falsy value: 0, '', false, NaN. ?? only falls back on
null and undefined — which is exactly what "this was not provided" means. For
optional numbers and booleans, || will eventually eat a legitimate value.
Optional tuple elements
Tuples take ? too, on trailing elements.
type LogEntry = [message: string, level?: 'warn' | 'error']
const plainLog: LogEntry = ['saved']
const warnLog: LogEntry = ['disk almost full', 'warn']LogEntry has a length of 1 | 2, and reading index 1 gives you
'warn' | 'error' | undefined. Like parameters, optional tuple elements have to come
last — a required element cannot follow an optional one.
Flipping optionality with Partial and Required
Marking fields one at a time gets old. Two utility types do it wholesale.
interface ServerConfig {
host: string
port: number
tls?: boolean
}
type ConfigPatch = Partial<ServerConfig>
// { host?: string; port?: number; tls?: boolean }
type FullConfig = Required<ServerConfig>
// { host: string; port: number; tls: boolean }
const patch: ConfigPatch = { port: 8080 }
const resolved: FullConfig = { host: 'localhost', port: 8080, tls: false }Partial makes every key optional, Required strips every ? away. Both are shallow —
they touch the top level only, and nested objects keep whatever optionality they had.
Most of the time you want something in between: a few keys optional, the rest untouched. That composes out of the pieces you already have.
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
type DraftConfig = PartialBy<ServerConfig, 'port'>
const draft: DraftConfig = { host: 'localhost' }Omit drops the chosen keys, Partial<Pick<...>> adds them back as optional, and the
intersection glues the two halves together. The
PartialByKeys challenge is this exact type, built
from scratch.
The lookup that may miss
Partial has a second job that is easy to miss: it is how you model a lookup table where
not every key is filled in.
type Locale = 'en' | 'de' | 'fr'
type TranslationTable = Partial<Record<Locale, string>>
const greetings: TranslationTable = { en: 'Hello', de: 'Hallo' }
const frenchGreeting = greetings.fr // string | undefinedA bare Record<Locale, string> would demand all three
keys and then promise every lookup returns a string. Wrapping it in Partial says what
is actually true — some keys are missing, and a lookup may come back empty. The union you
get back is the point, not an inconvenience.
Arrays and noUncheckedIndexedAccess
Array indexing is the one place TypeScript is optimistic by default.
declare const teamNames: string[]
const leadName = teamNames[0]leadName is typed string, even though the array could be empty and the value could be
undefined at runtime. The noUncheckedIndexedAccess flag fixes that: turn it on and
every index read and index-signature read becomes T | undefined, which is the honest
type. It is noisy on an existing codebase, and it catches a real class of bug.
Narrowing an optional value
Every optional read hands you a union, and every union needs narrowing before use.
function describeTls(config: ServerConfig): string {
if (config.tls === undefined) {
return 'tls not configured'
}
return config.tls ? 'tls on' : 'tls off'
}Note the explicit === undefined rather than a truthiness check. config.tls can be
false, and if (config.tls) would report a configured-but-disabled setting as "not
configured" — the same trap as ||.
Narrowing works on nested optional objects too, and it survives across the rest of the block.
function readCity(place: Company): string {
if (place.address === undefined) {
return 'unknown'
}
return place.address.city
}The catch is that this only holds while nothing can reassign the value. Move the read
into a callback or an await, and TypeScript drops the narrowing, because it cannot
prove the property is still there. Pull the value into a const first and the narrowing
sticks.
When not to reach for ?
Optional is the default answer to "this field might not be there", and it is often the wrong one. Two cases in particular.
The first is a field that is required in some states and absent in others. Marking it optional collapses both states into one type, and every consumer pays for it with a check that cannot fail in half the call sites. A discriminated union models the states properly and the check becomes meaningful.
The second is the half-populated object that gets filled in over several steps. A type
where every field is optional describes the intermediate states well and the finished
object badly — nothing stops you passing a half-built value into code that needs a
complete one. Keep the strict type, build up a Partial of it, and convert once at the
boundary where it is genuinely complete.
? is right when absence carries no extra meaning. When absence means "we are in a
different state", say that instead.
Wrapping up
? is one character doing four jobs, and the through-line is the same every time: it
makes something skippable on the way in, and it hands you a | undefined union on the
way out. Write side loose, read side strict.
The practical rules are short. Use ? when a key may be absent and | undefined when a
caller must decide. Prefer a default parameter over an optional one when you have a
sensible fallback. Use ?? and never || for optional numbers and booleans. And reach
for Partial and Required before hand-editing a dozen ? marks.
If you want to practise, Optional Keys and Get Optional both ask you to pull the optional members back out of a type — which turns out to be much harder than putting them in.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges