Skip to main content

@default

tl;dr@default is a Lumenize-custom JSDoc annotation (not part of typia's vocabulary) that fills missing optional fields with a JSON literal value before the validator runs. It must be on an optional field. Explicit null is preserved; missing or explicit undefined triggers the default.


Fill semantics

Given this interface:

interface Todo {
title: string;
/** @default 0 */
priority?: number;
}

The generated parse() wrapper fills priority before handing the value to the validator:

const result = await facet.parse({ title: 'Ship it' }, 'Todo');
expect(result).toEqual({
valid: true,
data: { title: 'Ship it', priority: 0 }, // priority filled from @default
});

What counts as "missing"?

The filler applies the default if the property is either absent or explicitly ****undefined. Any other value — including null, 0, '', and false — is preserved.

// Missing → default applied
const missing = await facet.parse({ title: 'x' }, 'Todo');
expect(missing.data).toMatchObject({ priority: 0 });

// Explicit undefined → default applied
const undef = await facet.parse({ title: 'x', priority: undefined }, 'Todo');
expect(undef.data).toMatchObject({ priority: 0 });

// Caller-supplied value wins (even 0, '', false)
const supplied = await facet.parse({ title: 'x', priority: 99 }, 'Todo');
expect(supplied.data).toMatchObject({ priority: 99 });

This keeps null meaningful:

interface Note {
/** @default 0 */
count?: number | null;
}
// ...
const nullResult = await facet.parse({ count: null }, 'Note');
expect(nullResult.data).toMatchObject({ count: null }); // default NOT applied

Grammar — JSON literals only

@default accepts any JSON literal. The annotation text is JSON.parsed as-is:

AcceptedExample
number@default 42, @default -1.5e3
string@default "hello" (double-quoted)
boolean@default true, @default false
null@default null
array@default [], @default [1, 2, 3], @default ["a", "b"]
object@default {"timeout": 30}, nested arbitrarily

Anything that doesn't round-trip through JSON.parse is rejected at compile time:

RejectedReason
@default undefinednot JSON
@default NaNnot JSON
@default Infinitynot JSON
@default 10nbigint syntax
@default 'x'single quotes
@default {foo: 1}unquoted keys
@default [1, 2,]trailing comma

The error message names the type, field, and offending literal text.

Required vs optional

@default on a required field is a hard error at compile time:

// This throws from generateParseModule():
interface X {
/** @default 0 */
x: number; // required — no `?`
}
// Error: @default on required field 'X.x' — declare the field optional
// (x?: ...) or remove the @default tag.

Rationale: a default on a required field is ambiguous — does the caller have to supply it or not? Making the field optional with a default answers clearly: the caller may omit it, and the system fills it. This may mean additional null checking in any code that consumes a parsed value. We may upgrade to address this in the future.

Nested recursion

@default recurses through the full value graph. Defaults on nested interfaces fire when the nested value is present but incomplete; defaults on array-valued fields fire when the array is missing entirely (per-element defaults fire when individual elements are present but incomplete).

interface Address {
street: string;
/** @default "US" */
country?: string;
}

interface User {
name: string;
address?: Address;
}
// ...
// Nested object: default fires inside the nested shape
const nested = await facet.parse({ name: 'Alice', address: { street: '1 Main' } }, 'User');
expect(nested.data).toMatchObject({
name: 'Alice',
address: { street: '1 Main', country: 'US' },
});
interface Tagged {
/** @default [] */
tags?: string[];
}
// ...
// Missing array → empty array
const tagged = await facet.parse({}, 'Tagged');
expect(tagged.data).toMatchObject({ tags: [] });

Containers of inline or named-interface types are walked element-by-element, so per-element defaults fire on every entry. This works for Array<T> / T[], Set<T>, and Map<K, T> (plus the Readonly variants). Shown here for Array<{...}> only:

interface Config {
servers?: Array<{
host: string;
/** @default 8080 */
port?: number;
}>;
}
// ...
const ok = await facet.parse(
{ servers: [{ host: 'a' }, { host: 'b', port: 9090 }] },
'Config',
);
expect(ok.data).toMatchObject({
servers: [
{ host: 'a', port: 8080 },
{ host: 'b', port: 9090 },
],
});

Guidance — don't stack deep nested defaults

If an interface has @default five levels deep inside one monolithic shape, readers will struggle. Lift the nested structure into its own named interface so the defaults attach to that interface's own optional fields:

// Harder to read — defaults buried inside an inline object
interface Config {
server?: {
retries?: {
/** @default 3 */
max?: number;
/** @default 100 */
backoffMs?: number;
};
};
}
// ...
// Easier to read — defaults attached to a named interface
interface RetryConfig {
/** @default 3 */
max?: number;
/** @default 100 */
backoffMs?: number;
}

interface ServerConfig {
retries?: RetryConfig;
}

interface Config {
server?: ServerConfig;
}

The recursion is identical; the readability is not.

Discriminated unions — recursion routes by the discriminator

When a field's declared type is a discriminated union (a union of inline shapes sharing a common literal-typed property), @default recursion inspects the discriminator at runtime and routes into the matching variant. Defaults on the chosen variant's fields fire normally; defaults on the other variant(s) don't.

interface Config {
payload?:
| {
kind: 'retry';
/** @default 3 */
max?: number;
}
| {
kind: 'cache';
/** @default 60 */
ttlSeconds?: number;
};
}
// ...
const retry = await facet.parse({ payload: { kind: 'retry' } }, 'Config');
expect(retry.data).toMatchObject({ payload: { kind: 'retry', max: 3 } });

Numeric-literal discriminators (code: 200 | 500) and boolean-literal discriminators (ok: true | false) work the same way as string-literal discriminators.

Known limitations

Non-discriminated multi-shape unions don't recurse

When a union of inline shapes has no common literal-typed discriminator to disambiguate variants at runtime, @default recursion doesn't know which variant's defaults to apply, so nested tags don't fire. You have two fixes: add a discriminator, or split into named interfaces.

// ❌ No shared discriminator — nested @default on `a.weight` won't fire.
interface Ambiguous {
payload?:
| { a: number; /** @default 1 */ weight?: number }
| { b: string };
}

// ✅ Add a discriminator — see "Discriminated unions" above.
interface Clear {
payload?:
| { kind: 'numeric'; a: number; /** @default 1 */ weight?: number }
| { kind: 'text'; b: string };
}

// ✅ Or split into named interfaces.
interface NumericPayload { a: number; /** @default 1 */ weight?: number; }
interface TextPayload { b: string; }
interface ClearNamed {
payload?: NumericPayload | TextPayload;
}

Single non-null plus null/undefined ({...} | null) is handled normally — it's the common "optional inline" pattern, not a multi-shape union.

When defaults fail validation

The filler runs before the validator, and the validator then sees the filled value. If the default literal doesn't satisfy the field type, the validator fails at the filled path:

interface Bad {
/** @default "hello" */
count?: number;
}
// ...
const result = await facet.parse({}, 'Bad');
expect(result).toMatchObject({
valid: false,
errors: [
{ path: '$input.count', expected: '(number | undefined)', value: 'hello' },
],
});

Nothing pre-checks the default against the field type at compile time — typia catches it on the first call through the filled path. Consistent error pipeline; no second error shape to learn.

Typia's Default<T> vs Lumenize's @default

Typia has a branded type Default<T> for primitives. It's metadata-only (surfaces in generated JSON Schema / random-value generators); it doesn't fill missing values at parse time. @lumenize/ts-runtime-parser-validator adds @default as a JSDoc annotation that does fill at parse time and accepts full JSON literals (arrays, objects, nested). The two can coexist on the same field — they don't conflict.