sdxc

[ Language & values ]

@sdxc/types

Shared TypeScript types

npm add @sdxc/types
pnpm add @sdxc/types
yarn add @sdxc/types
bun add @sdxc/types
Used by
auth-saas, blog

Shared TypeScript types for async return values, JSON boundaries and type-level checks.

Installation

npm add @sdxc/types

The package ships types only, so import everything with import type.

Usage

Name what an async function resolves to

import type { ResolvedType } from "@sdxc/types";

async function fetchUser(id: string): Promise<{ name: string; email: string }> {
	return { name: id, email: `${id}@example.com` };
}

type User = ResolvedType<typeof fetchUser>; // { name: string; email: string }

Accept only values that survive JSON

Use JSONValue as a generic bound: the caller keeps their own shape, and anything JSON cannot carry is rejected at the call site.

import type { JSONValue } from "@sdxc/types";

function enqueue<T extends JSONValue>(payload: T): T {
	return payload;
}

let job = enqueue({ id: 1, tags: ["news"], draft: false });
job.tags; // string[]

enqueue({ when: new Date() }); // Error: a Date comes back as a string
enqueue({ run: () => 1 }); // Error: functions are not JSON

Accept anything JSON.stringify can write

JSONSerializable adds objects with a toJSON, such as Date and URL.

import type { JSONSerializable } from "@sdxc/types";

function write<T extends JSONSerializable>(payload: T): string {
	return JSON.stringify(payload);
}

write({ publishedAt: new Date(), href: new URL("https://example.com") });

Type what comes back

JSONSerialized<T> is the shape a written value has after JSON.parse.

import type { JSONSerialized } from "@sdxc/types";

type Stored = JSONSerialized<{ id: number; publishedAt: Date; edit: () => void }>;
// { id: number; publishedAt: string }

API

ResolvedType<T extends (...args: any) => Promise<any>>

The value an async function resolves to; shorthand for Awaited<ReturnType<T>>.

JSONPrimitive

string | number | boolean | null. Take it where a value is compared, indexed or used as a key, so the signature says a structure has no meaning there.

JSONValue

JSONPrimitive | JSONValue[] | { [key: string]: JSONValue }: a value that comes back from JSON.parse identical to what was written. Use it as a generic bound (T extends JSONValue); as a plain parameter type it widens the argument and the caller loses their shape.

JSONSerializable

JSONValue plus { toJSON(): JSONSerializable } at any depth: every value JSON.stringify accepts. Take it where a value is written and JSONValue (or JSONSerialized<T>) where it is read back, since the reader receives the toJSON replacement.

JSONSerialized<T>

What T becomes after a JSON round trip: toJSON is applied, unwritable properties are dropped, and unwritable array elements become null. A JSONValue comes back unchanged.

JSONSerialized<Date>; // string
JSONSerialized<undefined[]>; // null[]
JSONSerialized<[string, Date]>; // [string, string]

It describes the shape, so it tracks nine levels of nesting and widens to JSONValue below that, and it keeps getters and inherited properties that JSON.stringify leaves out.

IsAny<T>

true when T is any, false for every other type, unknown included. Equivalent to 0 extends 1 & T ? true : false.

Pattern: Typing a consumer from its data function

Name the data once, at its source, and derive props and item types from it with indexed access.

import type { ResolvedType } from "@sdxc/types";

async function listPosts() {
	return { posts: [{ id: "1", title: "Hello" }], total: 1 };
}

type PostList = ResolvedType<typeof listPosts>;
type Post = PostList["posts"][number]; // { id: string; title: string }

interface PostRowProps {
	post: Post;
}

let pending = listPosts(); // hand the promise on unawaited
pending.then((list: PostList) => list.posts.map((post): PostRowProps => ({ post })));

Pattern: A typed queue across a JSON boundary

The producer may send a Date; the consumer is typed with the string it will actually receive.

import type { JSONSerializable, JSONSerialized } from "@sdxc/types";

class Topic<T extends JSONSerializable> {
	#messages: string[] = [];

	push(message: T): void {
		this.#messages.push(JSON.stringify(message));
	}

	pull(): JSONSerialized<T> | undefined {
		let raw = this.#messages.shift();
		return raw === undefined ? undefined : JSON.parse(raw);
	}
}

let posts = new Topic<{ id: number; publishedAt: Date }>();
posts.push({ id: 1, publishedAt: new Date() });

let message = posts.pull();
message?.publishedAt; // string

Pattern: A cache that only stores round-trippable values

When the value read back must be the value written, constrain the write to JSONValue.

import type { JSONValue } from "@sdxc/types";

class Cache {
	#entries = new Map<string, string>();

	set<T extends JSONValue>(key: string, value: T): void {
		this.#entries.set(key, JSON.stringify(value));
	}

	get<T extends JSONValue>(key: string): T | undefined {
		let raw = this.#entries.get(key);
		return raw === undefined ? undefined : JSON.parse(raw);
	}
}

let cache = new Cache();
cache.set("plans", ["free", "pro"]);
cache.set("now", new Date()); // Error: read back, it would be a string

Pattern: Filters over scalar fields

JSONPrimitive keeps comparisons to values that compare meaningfully.

import type { JSONPrimitive } from "@sdxc/types";

interface Condition {
	field: string;
	value: JSONPrimitive;
}

function eq(field: string, value: JSONPrimitive): Condition {
	return { field, value };
}

eq("plan", "pro");
eq("seats", null);
eq("plan", { tier: "pro" }); // Error: an object is not a JSONPrimitive

Pattern: Turning any into unknown

IsAny lets a helper refuse to pass any through, so a caller narrows the result of JSON.parse before using it.

import type { IsAny } from "@sdxc/types";

type Known<T> = IsAny<T> extends true ? unknown : T;

function read<T>(load: () => T): Known<T> {
	return load() as Known<T>;
}

let parsed = read(() => JSON.parse('{"id":1}')); // unknown
let count = read(() => 42); // number

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.