Handling Optional and Nullable Fields

Avatar of Hemanta SundarayHemanta Sundaray

JavaScript and TypeScript represent the absence of data in several distinct ways. When working with objects, missing fields typically fall into one of three specific categories:

  • Key omission: The key does not exist on the object at all.
  • Present but undefined: The key exists, but its value is explicitly undefined.
  • Present but null: The key exists, but its value is explicitly null.

Effect Schema provides specific APIs for each of these cases, allowing you to be precise about which forms of absence your application should accept.

Making Fields Optional

The simplest way to make a field optional is optionalKey. It creates an exact optional property, meaning the key can be omitted from the object entirely, but if it is present, it must match the schema’s type.

schema.ts
import { Schema } from "effect";
const UserSchema = Schema.Struct({
name: Schema.String,
age: Schema.optionalKey(Schema.Number),
});
// Type: { readonly name: string; readonly age?: number }
type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
// age is omitted — valid
console.log(decode({ name: "John" }));
// age is present — valid
console.log(decode({ name: "John", age: 24 }));
// age is present but wrong type — error
try {
decode({ name: "John", age: "twenty four" });
} catch (error) {
if (error instanceof Error) {
console.log("Error:", error.message);
}
}
// age is present but wrong type — error
try {
decode({ name: "John", age: undefined });
} catch (error) {
if (error instanceof Error) {
console.log("Error:", error.message);
}
}

Output:

Terminal
{ name: 'John' }
{ name: 'John', age: 24 }
Error: Expected number, got "twenty four"
at ["age"]
Error: Expected number, got undefined
at ["age"]

The age key can be absent, but if it’s there, it must be a number. Any other data type will result in a decoding failure.

Making Fields Optional with Undefined

Oftentimes you want a field that can be omitted or explicitly set to undefined. The optional function handles this case.

optional is equivalent to optionalKey(UndefinedOr(schema)). It creates a field that accepts three states: the key is absent, the key is present with undefined, or the key is present with a valid value.

schema.ts
import { Schema } from "effect";
const UserSchema = Schema.Struct({
name: Schema.String,
age: Schema.optional(Schema.Number),
});
// Type: { readonly name: string; readonly age?: number | undefined }
type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
// age is omitted — valid
console.log(decode({ name: "Steve" }));
// age is explicitly undefined — valid
console.log(decode({ name: "Steve", age: undefined }));
// age is present — valid
console.log(decode({ name: "Steve", age: 30 }));

Output:

Terminal
{ name: 'Steve' }
{ name: 'Steve', age: undefined }
{ name: 'Steve', age: 30 }

Mapping Optional Fields to Option

In the previous sections, we learnt how to represent optional fields as missing keys or undefined values. While Effect Schema lets us distinguish between missing keys and undefined values precisely, they are not always the most convenient shapes to work with in application code. In many cases, it is easier to convert all of these cases into a single representation: Option. Option is an Effect data type with only two possibilities: Some(value), which means a value is present, and None, which means it is absent. The key benefit of Option is that absence becomes explicit. Every consumer of the value must handle the None case.

Effect Schema provides two functions that transform optional fields into Option values during decoding.

OptionFromOptionalKey

OptionFromOptionalKey decodes a field that may be absent (an exact optional key) into an Option:

  • If the key is missing, the decoded value is Option.none().
  • If the key is present, the decoded value is Option.some(value).
schema.ts
import { Option, Schema } from "effect";
const UserSchema = Schema.Struct({
name: Schema.String,
nickname: Schema.OptionFromOptionalKey(Schema.String),
});
// Type: { readonly name: string; readonly nickname: Option.Option<string> }
type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
// Key is absent — None
const aron = decode({ name: "Aron" });
console.log(aron.nickname); // { _id: 'Option', _tag: 'None' }
console.log(Option.isNone(aron.nickname)); // true
// Key is present — Some
const bob = decode({ name: "Bob", nickname: "Bobby" });
console.log(bob.nickname); // { _id: 'Option', _tag: 'Some', value: 'Bobby' }
console.log(Option.getOrElse(bob.nickname, () => "no nickname")); // Bobby

OptionFromOptional

OptionFromOptional is the broader variant. It decodes a field that may be absent or explicitly undefined into an Option:

  • If the key is missing, the decoded value is Option.none().
  • If the value is undefined, the decoded value is Option.none().
  • Otherwise, the decoded value is Option.some(value).
schema.ts
import { Schema } from "effect";
const UserSchema = Schema.Struct({
name: Schema.String,
nickname: Schema.OptionFromOptional(Schema.String),
});
const decode = Schema.decodeUnknownSync(UserSchema);
// Key is absent - None
console.log(decode({ name: "Anne" }).nickname);
// Value is undefined - None
console.log(decode({ name: "Bob", nickname: undefined }).nickname);
// Value is present - Some
console.log(decode({ name: "Charlie", nickname: "Chuck" }).nickname);

Output:

Terminal
{ _id: 'Option', _tag: 'None' }
{ _id: 'Option', _tag: 'None' }
{ _id: 'Option', _tag: 'Some', value: 'Chuck' }

Nullable and Nullish Types

APIs commonly use null to represent empty values, and some systems use both null and undefined. Effect Schema provides three helpers for these cases.

NullOr

NullOr creates a schema that accepts either the wrapped type or null:

schema.ts
import { Schema } from "effect";
const UserSchema = Schema.Struct({
name: Schema.String,
bio: Schema.NullOr(Schema.String),
});
// Type: { readonly name: string; readonly bio: string | null }
type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
console.log(decode({ name: "Hemanta", bio: "Hello!" }));
console.log(decode({ name: "Hemanta", bio: null }));

Output:

schema.ts
{ name: 'Hemanta', bio: 'Hello!' }
{ name: 'Hemanta', bio: null }

The bio field is still required — you must provide the key. But the value can either be a string or null.

UndefinedOr

UndefinedOr creates a schema that either accepts the wrapped type or undefined.

schema.ts
import { Schema } from "effect";
const UserSchema = Schema.Struct({
name: Schema.String,
bio: Schema.UndefinedOr(Schema.String),
});
// Type: { readonly name: string; readonly bio: string | undefined }
type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
console.log(decode({ name: "Hemanta", bio: "Hello!" }));
console.log(decode({ name: "Hemanta", bio: undefined }));

Output:

Terminal
{ name: 'Hemanta', bio: 'Hello!' }
{ name: 'Hemanta', bio: undefined }

Note that UndefinedOr on its own does not make the key optional. The bio key must still be present in the input. It just allows undefined as a valid value. If you want the key to be omissible as well, use optional, which combines optionalKey with UndefinedOr.

NullishOr

NullishOr accepts the wrapped type, null or undefined.

schema.ts
import { Schema } from "effect";
const UserSchema = Schema.Struct({
name: Schema.String,
bio: Schema.NullishOr(Schema.String),
});
// Type: { readonly name: string; readonly bio: string | null | undefined }
type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
console.log(decode({ name: "Hemanta", bio: "Hello!" }));
console.log(decode({ name: "Hemanta", bio: null }));
console.log(decode({ name: "Hemanta", bio: undefined }));

Output:

Terminal
{ name: 'Hemanta', bio: 'Hello!' }
{ name: 'Hemanta', bio: null }
{ name: 'Hemanta', bio: undefined }

This is common when consuming APIs that don’t distinguish between null and undefined.

Mapping Nullable Fields to Option

The previous section mapped optional fields (missing keys and undefined values) into Option. The same idea applies to nullable fields. Instead of working with string | null in your application code, you can decode the value into an Option, collapsing null into None and keeping valid values as Some.

OptionFromNullOr

OptionFromNullOr decodes a required field whose value might be null:

  • null becomes Option.none().
  • Any other valid value becomes Option.some(value).
schema.ts
import { Option, Schema } from "effect";
const UserSchema = Schema.Struct({
name: Schema.String,
bio: Schema.OptionFromNullOr(Schema.String),
});
// Type: { readonly name: string; readonly bio: Option.Option<string> }
type User = typeof UserSchema.Type;
const decode = Schema.decodeUnknownSync(UserSchema);
// null - None
const anne = decode({ name: "Anne", bio: null });
console.log(Option.isNone(anne.bio)); // true
// string - Some
const bob = decode({ name: "Bob", bio: "Hello!" });
console.log(Option.getOrElse(bob.bio, () => "no bio")); // Hello!

Quick Reference

FunctionWhat it does
optionalKeyKey can be omitted, but if present, must match the type
optionalKey can be omitted or set to undefined
NullOrValue can be the type or null
UndefinedOrValue can be the type or undefined
NullishOrValue can be the type, null, or undefined
OptionFromOptionalKeyMissing key becomes None, present value becomes Some
OptionFromOptionalMissing key or undefined becomes None, present value becomes Some
OptionFromNullOrnull becomes None, present value becomes Some

Last updated on March 21, 2026

Sign in to save progress

Stay in the loop

Get notified when new Effect Atom related content is published.