Skip to content

Latest commit

 

History

History
447 lines (366 loc) · 16.5 KB

dependentrecordtypes.md

File metadata and controls

447 lines (366 loc) · 16.5 KB

Dependent Record Types

This document describes an EXTENSION. The where clause it uses - a predicate over a record's own fields, as } where this.country == 'US' { … } - is not part of the core specification, which places it with "the dependent record type extension, checked at the boundaries #table-check-sites enumerates rather than at specialization" and lists it among "the obligations this specification has incurred outward, not a specification of the extensions themselves". An engine implementing only the core will refuse these examples, and that refusal is correct rather than a defect.

The two where forms the core DOES specify - a compile-time bound over generic parameters, and a contract naming return - are unrelated to this one beyond sharing the keyword: they are checked at specialization and at evaluation respectively, and a shared keyword is not a shared rule.

In addition to primitive metadata it's possible to have minimal syntax to define dependent record types and further improve refinement typing. Taken from JSON Schema the goal is to support dependentRequired, dependentSchemas, and if/then/else. Consider the following examples:

// dependentRequired
{
	"type": "object",
	"properties": {
		"name": { "type": "string" },
		"creditCard": { "type": "number" },
		"billingAddress": { "type": "string" }
	},
	"required": ["name"],
	"dependentRequired": {
		"creditCard": ["billingAddress"],
		"billingAddress": ["creditCard"]
	}
}
// dependentSchemas
{
	"type": "object",
	"properties": {
		"name": { "type": "string" },
		"creditCard": { "type": "number" }
	},
	"required": ["name"],
	"dependentSchemas": {
		"creditCard": {
			"properties": {
				"billingAddress": { "type": "string" }
			},
			"required": ["billingAddress"]
		}
	}
}
// if/then/else:
{
	"type": "object",
	"properties": {
		"streetAddress": {
			"type": "string"
		},
		"country": {
			"default": "US",
			"enum": ["US", "CA"]
		}
	},
	"if": {
		"properties": {
			"country": { "const": "US" }
		}
	},
	"then": {
		"properties": {
			"postalCode": { "pattern": "[0-9]{5}(-[0-9]{4})?" }
		}
	},
	"else": {
		"properties": {
			"postalCode": { "pattern": "[A-Z][0-9][A-Z] [0-9][A-Z][0-9]" }
		}
	}
}

The proposed syntax is to use where and is to define these dependencies.

// dependentRequired
type Payment = {
	name: string,
	creditCard?: number,
	billingAddress?: string,
} where (this.creditCard != null) == (this.billingAddress != null);

// dependentSchemas
type PaymentWithSchema = {
	name: string,
	creditCard?: number,
	billingAddress?: string,
} where if (this.creditCard != null) {
	this.billingAddress is string
	// or
	//this is { billingAddress: string }
};

// if/then/else
type USPostalCode = string.<{ pattern: /^[0-9]{5}(-[0-9]{4})?$/ }>;
type CAPostalCode = string.<{ pattern: /^[A-Z]\d[A-Z] \d[A-Z]\d$/ }>;
type Address = {
	streetAddress: string,
	country: 'US' | 'CA',
} where if (this.country == 'US') {
	this is { postalCode: USPostalCode }
} else {
	this is { postalCode: CAPostalCode }
};

In the Address code it should be noted that this design means developers don't have to use discriminated unions. For reference:

type Address =
	| { streetAddress: string, country: 'US', postalCode: USPostalCode }
	| { streetAddress: string, country: 'CA', postalCode: CAPostalCode }

Also to be clear multiple where clauses can be used.

type ... = {
}
where if (...) {} else {}
where if (...) {} else {};

Narrowing

const addr: Address = { streetAddress: '123 Main', country: 'CA', postalCode: 'M4W 3R8' };

if (addr.country == 'US') {
	// Compiler narrows addr.postalCode to USPostalCode
}

Discriminated where-chains

A where chain that switches on one member against constants is a discriminated union written from the inside, and where the chain qualifies it denotes that union. It qualifies in either of the two forms a chain is written in.

The if/else form qualifies when every condition is an equality test between a member of this and a literal or enumerator constant, every condition tests the same member, that member's declared type is a closed set of those constants - a literal union or an enum - and the chain is total, meaning it ends in else or its conditions exhaust the member's type. The where match form qualifies when its subject is one member of this of such a type, every clause is unguarded against a constant of it or an or of constants, no constant is named twice, and the clauses name them all; each clause is a branch, and a clause naming several constants contributes a member for each. Each branch contributes the declared members with the discriminant narrowed to the constant that branch tested, plus whatever shape the branch asserts; a branch predicate that isn't a shape assertion rides along as a refinement of that branch and contributes no members. A branch's predicates narrow within the branch whether or not they contribute members: any narrowing form over a path on this - this is T, this.p is T, this.data !== undefined - contributes there what it would contribute over the same path on a value, and a conjunction contributes both sides.

// Address above qualifies: one member, two literal constants, a final else.
// It denotes:
//   | { streetAddress: string, country: 'US', postalCode: USPostalCode }
//   | { streetAddress: string, country: 'CA', postalCode: CAPostalCode }

Which is the union printed for reference above, so the equivalence that section asserts is a rule rather than a remark. What it buys is the property an enum and a sealed class already have and this form did not: pattern matching checks a match over an Address exhaustive from one arm per country with no default, and adding 'MX' to country becomes a compile-time error at every such match instead of silently routing through a default clause.

A chain that doesn't qualify denotes nothing but itself: a != or ordering condition, conditions over two different members, or a chain with no else over a member whose type isn't exhausted. Those keep every other behavior described here - the predicates run at boundaries, is evaluates them, narrowing works off the field correspondences - and only forgo the totality claim, so code matching on them writes a default. Nothing is lost by declining to normalize; the alternative is a checker that reasons about predicates on a budget, and no other rule in this proposal asks for one.

The derived union is used for checking, not as the type's identity. Reflect.typeOf reports the dependent record type, assignability compares against it, and Address remains one type object rather than two.

Assignment design choices

In the previous example for Payment setting either creditCard or billingAddress would place an instance into an invalid state. (Both need to be defined).

type Payment = {
	name: string,
	creditCard?: number,
	billingAddress?: string
} where (this.creditCard != null) == (this.billingAddress != null);

const payment: Payment = {
	name: 'Alice'
}; // Valid: Neither creditCard nor billingAddress are present.

payment.name = 'Bob'; // Valid: name has no constraints

Solution

Fields referenced in a where clause are dependent: they cannot be validated on independent assignment, so the clause is checked at boundaries instead - construction, function calls, and object assignment. This is the rule the examples in this document assume.

Two more choices the same rule settles. A dependent record type's identity is its declaration's: the predicate is a function value compared by SameValue, so two textually identical where blocks in two modules are two types, and sharing one means declaring once and importing. And a where block is one predicate: conjunction is written by nesting one refined type in another, and a multi-assertion block would need a separator the grammar does not have.

The spread operator can be used to set a fully valid object:

const updatedPayment: Payment = {
	...payment,
	creditCard: 4111222233334444,
	billingAddress: '123 Main St'
}; // Valid: Both fields are provided. The where clause evaluates to true.

const invalidPayment: Payment = {
	...payment,
	creditCard: 1234123412341234
}; // Compile or runtime error: 'where' clause validation failed at construction. (In this case it would be a compile time error).

Dependent fields are checked at boundaries:

const payment: Payment = { name: 'Alice' }; // Checked, construction
payment.creditCard = 4111111111111111; // Not checked
payment.billingAddress = '123 Main St'; // Not checked
validate(payment); // Checked, function call
const payment2: Payment = payment; // Checked, object assignment

This allows an object to be put into an invalid state momentarily while the compiler ensures it doesn't stay invalid across a boundary. When validity cannot be determined at compile time, a runtime check of the where clause is inserted at the boundary and throws on failure. To branch on validity instead of throwing, test explicitly:

if (payment is Payment) { 
	example(payment);
}

Limitations

Consider the following code that allows a name to be a 'strict' or 'loose' mode.

type User = {
	mode: 'strict' | 'loose',
	name: string
} where if (this.mode == 'strict') {
	this.name is string.<{ pattern: /^[a-z]+$/ }> // lowercase only
} else {
	this.name is string.<{ pattern: /^[a-zA-Z]+$/ }> // mixed case
};

const user: User = { mode: 'strict', name: 'alice' }; // Pretend 'alice' is a dynamic value
// Compiler knows:
// user.mode is 'strict'
// user.name is string.<{ pattern: /^[a-z]+$/ }>

user.mode = 'loose';

example(user);
// The compiler cannot prove the where clause still holds, so a runtime
// check is inserted at the call boundary. It passes here since 'alice'
// matches the loose pattern, but the check cannot be elided.

If the compiler were smart it would know that strings matching the 'strict' regex are a subset of those matching the 'loose' regex and elide the check. It's unlikely such a subset operation would be added (or implemented by a user in the metadata subtype operation, at least in general), so the compiler falls back to the inserted runtime check. To branch on validity instead:

if (user is User) {
	example(user);
}

Note: There's a comment about 'alice' being a dynamic value because a hardcoded literal would propagate and validate. That is, the compiler would be totally fine with the above example and correctly determine that no runtime check is required.

Examples

JSON Serialization

A native, fused version of this boundary (parsing, validation, and typed allocation in one pass) is specified in Serialization. The reflective pattern below remains the way to express wire-name mappings and custom policies.

// String constraints like pattern/minLength/maxLength come from the
// StringBounds meta type defined in the primitive metadata proposal.

const schemaKey = Symbol('schema');

type SerializeData = {
	name: string,
	wireName: string
};

partial class ClassMetadata {
	[schemaKey]: [].<SerializeData> = [];
}

// @field() registers a field for serialization with an optional wire name
function field<T: type, TClass: type>(
	{ name, metadata }: Reflect.ClassField.<T, TClass>
) where typeof name == 'string' {
	metadata[schemaKey].push({ name, wireName: name });
}
function field<T: type, TClass: type>(
	wireName: string,
	{ name, metadata }: Reflect.ClassField.<T, TClass>
) where typeof name == 'string' {
	metadata[schemaKey].push({ name, wireName });
}

function serialize<T: type>(instance: T): { [key: string]: any } {
	const result: { [key: string]: any } = {};
	for (const { name, wireName } of Reflect.getMetadata.<Reflect.Class, T>()[schemaKey]) {
		result[wireName] = instance[name];
	}
	return result;
}

function deserialize<T: type>(cls: { new(): T }, data: { [key: string]: any }): T {
	const instance = new cls();
	
	// 1. Mutation phase: Populate the fields.
	// Scalar bounds (like string or union types) are checked immediately on assignment.
	// However, cross-field 'where' clauses are not evaluated yet, allowing us to assign fields in any order without triggering false validation errors.
	for (const { name, wireName } of Reflect.getMetadata.<Reflect.Class, T>()[schemaKey]) {
		instance[name] = data[wireName];
	}

	// 2. Boundary phase: Now that the object is fully populated, we assert the cross-field invariants. The `is` operator evaluates the `where` clause.
	if (!(instance is T)) {
		throw new TypeError(`Cross-field validation failed for ${cls.name}`);
	}

	return instance;
}

class AddressResponse {
	@field('street_address')
	streetAddress: string;

	@field
	country: 'US' | 'CA';

	// The base type is just string
	@field('postal_code')
	postalCode: string;
} where if (this.country == 'US') {
	this.postalCode is USPostalCode
} else {
	this.postalCode is CAPostalCode
};

This does raise a question though about decorators usage in dependent record types. In the above usage all decorator usage with postalCode are identical. I mention this to say this isn't allowed:

class AddressResponse {
	@field('street_address')
	streetAddress: string;

	@field
	country: 'US' | 'CA';
} where if (this.country == 'US') {
	this is { @field('zip_code') postalCode: USPostalCode };
} else {
	this is { @field('postal_code') postalCode: CAPostalCode }
};

Instead this form would be used, as decorators are only allowed in the class body:

class AddressResponse {
	@field('street_address') streetAddress: string;
	@field country: 'US' | 'CA';

	@field('zip_code') usZip?: USPostalCode;
	@field('postal_code') caZip?: CAPostalCode;
} where if (this.country == 'US') {
	this.usZip !== undefined && this.caZip === undefined;
} else {
	this.caZip !== undefined && this.usZip === undefined;
};

Network Messages

Showing where match syntax. A match is an expression, so it is a predicate like any other; in this position it carries an implicit default: false;, since a predicate that matches no clause has answered that the value is not of the type. Its semantics are otherwise pattern matching's.

type NetworkState = {
	status: 'idle' | 'loading' | 'success' | 'error',
	data?: any,
	errorMessage?: string
} where match (this.status) {
	when 'idle' | 'loading': 
		this.data === undefined && this.errorMessage === undefined;
	when 'success': 
		this.data !== undefined && this.errorMessage === undefined;
	when 'error': 
		this.errorMessage !== undefined && this.data === undefined;
};

function renderUI(state: NetworkState) {
	match (state) {
		when { status: 'success' }:
			renderData(state.data); // state.data cannot be undefined
		when { status: 'error' }: 
			renderError(state.errorMessage); // state.errorMessage cannot be undefined
		when { status: 'idle' | 'loading' }:
			renderSpinner();
	}
}

A where match on one member against constants is a discriminating chain in the sense above, so NetworkState denotes the union of its four branches and the match in renderUI is checked exhaustive with no default. The branch predicates narrow too: a branch asserting this.data !== undefined contributes that fact where its branch is taken, which is why state.data is not optional inside the 'success' arm.

Business Logic

type DatabaseCommand = {
	userRole: 'admin' | 'editor' | 'viewer',
	action: 'insert' | 'update' | 'delete' | 'read',
	targetTable: string
} where match (this.userRole) {
	when 'viewer': 
		this.action === 'read';
	when 'editor': 
		this.action !== 'delete';
	when 'admin': 
		true; // Admins can do anything
};

On construction, this would throw if userRole doesn't match the allowed action.

Misc Example

type Email = {
	to: string.<{ pattern: /@/ }>,
	subject?: string,
	body?: string
} where (this.subject?.length ?? 0) > 0 || (this.body?.length ?? 0) > 0;

// The optional-field draft shape, written out since utility types like
// TypeScript's Partial aren't part of the proposal:
type EmailDraft = {
	to?: string.<{ pattern: /@/ }>,
	subject?: string,
	body?: string
};

function draftEmail(): EmailDraft {
	return {};
}

function sendEmail(ref draft: EmailDraft) {
	if (!(draft is Email)) {
		throw new Error("Invalid email draft");
	}
	// Sending...
}

const msg = draftEmail();
msg.to = "alice@example.com";
// No subject or body

sendEmail(ref msg); // throws