Commit 40c0aa61eee for woocommerce

commit 40c0aa61eee4211af04bbf64f657aa99da548ba2
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date:   Tue Sep 8 00:38:30 2026 +0200

    Refactor and breakdown CheckoutFields class into subclasses and traits (#68081)

    Extract additional checkout field type behavior into per-type classes

    Moves the per-type branching in CheckoutFields into a field type class per
    supported type, moves value storage into a CheckoutFieldsStorage trait, and
    moves the core field definitions into CoreCheckoutFields.

diff --git a/plugins/woocommerce/changelog/wooplug-6456-checkout-field-types b/plugins/woocommerce/changelog/wooplug-6456-checkout-field-types
new file mode 100644
index 00000000000..5202aaf7fe4
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6456-checkout-field-types
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Move additional checkout field type behavior into per-type classes, and field value storage into a trait, with no change in behavior.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index b0323bab2f7..6df50b7b695 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -52767,12 +52767,6 @@ parameters:
 			count: 1
 			path: src/Blocks/Domain/Package.php

-		-
-			message: '#^Instanceof between WC_Data and WC_Data will always evaluate to true\.$#'
-			identifier: instanceof.alwaysTrue
-			count: 1
-			path: src/Blocks/Domain/Services/CheckoutFields.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:add_fields_data\(\) has no return type specified\.$#'
 			identifier: missingType.return
@@ -52791,36 +52785,6 @@ parameters:
 			count: 1
 			path: src/Blocks/Domain/Services/CheckoutFields.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:process_checkbox_field\(\) never returns false so it can be removed from the return type\.$#'
-			identifier: return.unusedType
-			count: 1
-			path: src/Blocks/Domain/Services/CheckoutFields.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:process_field_options\(\) should return array but returns array\|false\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Blocks/Domain/Services/CheckoutFields.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:sync_customer_additional_fields_with_order\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Blocks/Domain/Services/CheckoutFields.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:sync_order_additional_fields_with_customer\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Blocks/Domain/Services/CheckoutFields.php
-
-		-
-			message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, array given\.$#'
-			identifier: argument.type
-			count: 2
-			path: src/Blocks/Domain/Services/CheckoutFields.php
-
 		-
 			message: '#^Parameter \#3 \$context of static method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFieldsSchema\\Validation\:\:get_field_schema_with_context\(\) expects string, string\|null given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/AbstractFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/AbstractFieldType.php
new file mode 100644
index 00000000000..9daa9bc25e3
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/AbstractFieldType.php
@@ -0,0 +1,213 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes;
+
+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\Validation;
+use WP_Error;
+
+/**
+ * Base class for additional checkout field types.
+ *
+ * Provides passthrough defaults so each field type only overrides the behavior it needs. New
+ * type-level behavior should be added here with a default implementation so existing subclasses
+ * keep working.
+ *
+ * phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter -- The passthrough defaults keep the
+ * signature subclasses override.
+ */
+abstract class AbstractFieldType {
+
+	/**
+	 * Validates the options that apply to every field type: callbacks, hidden state, and rule schemas.
+	 *
+	 * Subclasses adding checks should call this parent method first.
+	 *
+	 * @param array $options The options supplied during field registration.
+	 * @return bool False if an error should prevent registration, true otherwise.
+	 */
+	public function validate_options( array $options ): bool {
+		$id = $options['id'];
+
+		if ( empty( $options['label'] ) ) {
+			return $this->registration_error( $id, 'The field label is required.', '8.6.0' );
+		}
+
+		foreach ( array( 'sanitize_callback', 'validate_callback' ) as $callback ) {
+			if ( ! empty( $options[ $callback ] ) && ! is_callable( $options[ $callback ] ) ) {
+				return $this->registration_error( $id, sprintf( 'The %s must be a valid callback.', $callback ), '8.6.0' );
+			}
+		}
+
+		if ( ! empty( $options['hidden'] ) && true === $options['hidden'] ) {
+			// Not an error: the field is still registered, just as a visible one.
+			$this->doing_it_wrong( sprintf( 'Registering a field with hidden set to true is not supported. The field "%s" will be registered as visible.', $id ), '8.6.0' );
+		}
+
+		foreach ( array( 'required', 'hidden', 'validation' ) as $rule_field ) {
+			if ( empty( $options[ $rule_field ] ) || ( 'validation' !== $rule_field && is_bool( $options[ $rule_field ] ) ) ) {
+				continue;
+			}
+
+			$valid = Validation::is_valid_schema( $options[ $rule_field ] );
+
+			if ( is_wp_error( $valid ) ) {
+				return $this->registration_error( $id, $rule_field . ': ' . $valid->get_error_message(), '8.6.0' );
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Processes and validates the options supplied during field registration.
+	 *
+	 * @param array $field_data The field data array to be updated.
+	 * @param array $options    The options supplied during field registration.
+	 * @return array|false The updated $field_data array, or false if an error should prevent registration.
+	 */
+	final public function process_options( array $field_data, array $options ) {
+		$field_data['attributes'] = $this->process_attributes( $field_data['id'], $field_data['attributes'] );
+
+		return $this->process_type_options( $field_data, $options );
+	}
+
+	/**
+	 * Processes the type-specific options supplied during field registration.
+	 *
+	 * @param array $field_data The field data array, with the options common to every type already applied.
+	 * @param array $options    The options supplied during field registration.
+	 * @return array|false The updated $field_data array, or false if an error should prevent registration.
+	 */
+	protected function process_type_options( array $field_data, array $options ) {
+		return $field_data;
+	}
+
+	/**
+	 * Processes the attributes supplied during field registration.
+	 *
+	 * Invalid attributes are dropped with a warning rather than preventing registration.
+	 *
+	 * @param string $id         The field ID.
+	 * @param mixed  $attributes The attributes supplied during field registration.
+	 * @return array The processed attributes.
+	 */
+	protected function process_attributes( $id, $attributes ): array {
+		if ( empty( $attributes ) ) {
+			return [];
+		}
+
+		if ( ! is_array( $attributes ) ) {
+			$this->doing_it_wrong( sprintf( 'An invalid attributes value was supplied when registering field with id: "%s". Attributes must be a non-empty array.', $id ), '8.6.0' );
+			return [];
+		}
+
+		// These are formatted in camelCase because React components expect them that way.
+		$allowed_attributes = [ 'maxLength', 'readOnly', 'pattern', 'autocomplete', 'autocapitalize', 'title' ];
+
+		$valid_attributes = array_filter(
+			$attributes,
+			function ( $_, $key ) use ( $allowed_attributes ) {
+				return in_array( $key, $allowed_attributes, true ) || strpos( $key, 'aria-' ) === 0 || strpos( $key, 'data-' ) === 0;
+			},
+			ARRAY_FILTER_USE_BOTH
+		);
+
+		if ( count( $attributes ) !== count( $valid_attributes ) ) {
+			$invalid_attributes = array_keys( array_diff_key( $attributes, $valid_attributes ) );
+			$this->doing_it_wrong( sprintf( 'Invalid attribute found when registering field with id: "%s". Attributes: %s are not allowed.', $id, implode( ', ', $invalid_attributes ) ), '8.6.0' );
+		}
+
+		return array_map( 'esc_attr', $valid_attributes );
+	}
+
+	/**
+	 * Sanitizes a submitted value for this field type.
+	 *
+	 * @param mixed $value The submitted value.
+	 * @param array $field The field.
+	 * @return mixed The sanitized value.
+	 */
+	public function sanitize( $value, array $field ) {
+		return $value;
+	}
+
+	/**
+	 * The validate_callback a field gets when it does not declare its own: rejects empty required fields.
+	 *
+	 * Unlike validate(), a field's validate_callback can be replaced at registration, so nothing here is
+	 * mandatory for the type.
+	 *
+	 * @param mixed $value The submitted value.
+	 * @param array $field The field.
+	 * @return WP_Error|void An error if the value is invalid.
+	 */
+	public function default_validate( $value, $field ) {
+		if ( true === $field['required'] && empty( $value ) ) {
+			return new WP_Error(
+				'woocommerce_required_checkout_field',
+				sprintf(
+					// translators: %s is field key.
+					__( 'The field %s is required.', 'woocommerce' ),
+					$field['id']
+				)
+			);
+		}
+	}
+
+	/**
+	 * Validates a submitted value against the constraints of this field type.
+	 *
+	 * @param mixed $value The submitted value.
+	 * @param array $field The field.
+	 * @return \WP_Error|null Error if the value is not valid for the field type, null otherwise.
+	 */
+	public function validate( $value, array $field ) {
+		return null;
+	}
+
+	/**
+	 * Formats a stored value for display based on the field type.
+	 *
+	 * @param mixed $value The stored value.
+	 * @param array $field The field.
+	 * @return mixed The formatted value.
+	 */
+	public function format_value( $value, array $field ) {
+		return $value;
+	}
+
+	/**
+	 * Applies type-specific arguments to a field before it is rendered with woocommerce_form_field().
+	 *
+	 * @param array $form_field The woocommerce_form_field() arguments built from the field.
+	 * @return array The updated arguments.
+	 */
+	public function prepare_form_field( array $form_field ): array {
+		return $form_field;
+	}
+
+	/**
+	 * Reports a field registration error and prevents the field from being registered.
+	 *
+	 * @param string $id      The ID of the field being registered.
+	 * @param string $reason  The reason the field cannot be registered.
+	 * @param string $version The version the misuse was introduced in.
+	 * @return false
+	 */
+	protected function registration_error( string $id, string $reason, string $version ): bool {
+		return $this->doing_it_wrong( sprintf( 'Unable to register field with id: "%s". %s', $id, $reason ), $version );
+	}
+
+	/**
+	 * Reports a field registration misuse that does not prevent the field from being registered.
+	 *
+	 * @param string $message The message describing the misuse.
+	 * @param string $version The version the misuse was introduced in.
+	 * @return false
+	 */
+	protected function doing_it_wrong( string $message, string $version ): bool {
+		_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), esc_html( $version ) );
+		return false;
+	}
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php
new file mode 100644
index 00000000000..b2233c62848
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php
@@ -0,0 +1,64 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes;
+
+/**
+ * The "checkbox" additional checkout field type.
+ */
+class CheckboxFieldType extends AbstractFieldType {
+
+	/**
+	 * Processes the options for a checkbox field and returns the new field_options array.
+	 *
+	 * @param array $field_data The field data array to be updated.
+	 * @param array $options    The options supplied during field registration.
+	 * @return array|false The updated $field_data array, or false if an error was encountered.
+	 */
+	protected function process_type_options( array $field_data, array $options ) {
+		$id                     = $options['id'];
+		$field_data['required'] = $options['required'] ?? false;
+
+		if ( false === $field_data['required'] && ! empty( $options['error_message'] ) ) {
+			$this->doing_it_wrong( sprintf( 'Passing an error message to a non-required checkbox "%s" will have no effect. The error message has been removed from the field.', $id ), '9.8.0' );
+			unset( $field_data['error_message'] );
+		}
+
+		if ( isset( $options['error_message'] ) && ! is_string( $options['error_message'] ) ) {
+			$this->doing_it_wrong( sprintf( 'The error_message property for field with id: "%s" must be a string, you passed %s. A default message will be shown.', $id, gettype( $options['error_message'] ) ), '9.8.0' );
+			unset( $field_data['error_message'] );
+		}
+
+		// The client expects the error message in camelCase.
+		if ( isset( $field_data['error_message'] ) ) {
+			$field_data['errorMessage'] = $field_data['error_message'];
+			unset( $field_data['error_message'] );
+		}
+
+		return $field_data;
+	}
+
+	/**
+	 * Formats a stored checkbox value as Yes/No for display.
+	 *
+	 * @param mixed $value The stored value.
+	 * @param array $field The field.
+	 * @return string
+	 */
+	public function format_value( $value, array $field ) {
+		return $value ? __( 'Yes', 'woocommerce' ) : __( 'No', 'woocommerce' );
+	}
+
+	/**
+	 * Sets the checked and unchecked values woocommerce_form_field() should submit.
+	 *
+	 * @param array $form_field The woocommerce_form_field() arguments built from the field.
+	 * @return array The updated arguments.
+	 */
+	public function prepare_form_field( array $form_field ): array {
+		$form_field['checked_value']   = '1';
+		$form_field['unchecked_value'] = '0';
+
+		return $form_field;
+	}
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
new file mode 100644
index 00000000000..e8c96fb140e
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
@@ -0,0 +1,88 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes;
+
+use Automattic\WooCommerce\Utilities\TimeUtil;
+use WP_Error;
+
+/**
+ * The "date" additional checkout field type.
+ *
+ * Values are calendar dates in YYYY-MM-DD format with no time or timezone component.
+ */
+class DateFieldType extends AbstractFieldType {
+
+	/**
+	 * Trims whitespace from submitted date values before they are validated and stored.
+	 *
+	 * @param mixed $value The submitted value.
+	 * @param array $field The field.
+	 * @return mixed The sanitized value.
+	 */
+	public function sanitize( $value, array $field ) {
+		return is_string( $value ) ? trim( $value ) : $value;
+	}
+
+	/**
+	 * Validates that a submitted value is a real calendar date.
+	 *
+	 * @param mixed $value The submitted value.
+	 * @param array $field The field.
+	 * @return WP_Error|null Error if the value is not valid, null otherwise.
+	 */
+	public function validate( $value, array $field ) {
+		// An empty value is not a type error. Required fields are handled by the field's validation callback.
+		if ( null === $value || '' === $value ) {
+			return null;
+		}
+
+		if ( ! is_string( $value ) || ! TimeUtil::is_valid_date( $value, 'Y-m-d' ) ) {
+			return new WP_Error(
+				'woocommerce_invalid_checkout_field',
+				sprintf(
+					/* translators: %s: is the field label */
+					__( 'Please provide a valid %s in YYYY-MM-DD format.', 'woocommerce' ),
+					$field['label']
+				)
+			);
+		}
+
+		return null;
+	}
+
+	/**
+	 * Formats a stored YYYY-MM-DD date using the store's date format.
+	 *
+	 * @param mixed $value The stored value.
+	 * @param array $field The field.
+	 * @return mixed The formatted date, or the value unchanged if it could not be parsed.
+	 */
+	public function format_value( $value, array $field ) {
+		$date = is_string( $value ) ? $this->parse_date( $value ) : null;
+
+		if ( null === $date ) {
+			return $value;
+		}
+
+		$formatted = wp_date( wc_date_format(), $date->getTimestamp() );
+
+		return false === $formatted ? $value : $formatted;
+	}
+
+	/**
+	 * Parses a YYYY-MM-DD date in the store's timezone.
+	 *
+	 * @param string $value The date to parse.
+	 * @return \DateTimeImmutable|null The date, or null if it is not a real calendar date.
+	 */
+	private function parse_date( string $value ) {
+		if ( ! TimeUtil::is_valid_date( $value, 'Y-m-d' ) ) {
+			return null;
+		}
+
+		$date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value, wp_timezone() );
+
+		return $date ? $date : null;
+	}
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/SelectFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/SelectFieldType.php
new file mode 100644
index 00000000000..bc263f29eeb
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/SelectFieldType.php
@@ -0,0 +1,82 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes;
+
+/**
+ * The "select" additional checkout field type.
+ */
+class SelectFieldType extends AbstractFieldType {
+
+	/**
+	 * Processes the options for a select field and returns the new field_options array.
+	 *
+	 * @param array $field_data The field data array to be updated.
+	 * @param array $options    The options supplied during field registration.
+	 * @return array|false The updated $field_data array, or false if an error was encountered.
+	 */
+	protected function process_type_options( array $field_data, array $options ) {
+		$id = $options['id'];
+
+		if ( empty( $options['options'] ) || ! is_array( $options['options'] ) ) {
+			return $this->registration_error( $id, 'Fields of type "select" must have an array of "options".', '8.6.0' );
+		}
+
+		$cleaned_options = [];
+
+		foreach ( $options['options'] as $option ) {
+			if ( ! isset( $option['value'], $option['label'] ) ) {
+				return $this->registration_error( $id, 'Fields of type "select" must have an array of "options" and each option must contain a "value" and "label" member.', '8.6.0' );
+			}
+
+			$value = sanitize_text_field( $option['value'] );
+
+			if ( isset( $cleaned_options[ $value ] ) ) {
+				$this->doing_it_wrong( sprintf( 'Duplicate key found when registering field with id: "%s". The value in each option of "select" fields must be unique. Duplicate value "%s" found. The duplicate key will be removed.', $id, $value ), '8.6.0' );
+				continue;
+			}
+
+			$cleaned_options[ $value ] = [
+				'value' => $value,
+				'label' => sanitize_text_field( $option['label'] ),
+			];
+		}
+
+		$field_data['options'] = array_values( $cleaned_options );
+
+		if ( isset( $field_data['placeholder'] ) ) {
+			$field_data['placeholder'] = sanitize_text_field( $field_data['placeholder'] );
+		}
+
+		return $field_data;
+	}
+
+	/**
+	 * Formats a stored option value as its registered label for display.
+	 *
+	 * @param mixed $value The stored value.
+	 * @param array $field The field.
+	 * @return mixed The option label, or the value unchanged if it is not a registered option.
+	 */
+	public function format_value( $value, array $field ) {
+		$options = array_column( $field['options'], 'label', 'value' );
+
+		return $options[ $value ] ?? $value;
+	}
+
+	/**
+	 * Maps the registered options to the value => label format woocommerce_form_field() expects.
+	 *
+	 * @param array $form_field The woocommerce_form_field() arguments built from the field.
+	 * @return array The updated arguments.
+	 */
+	public function prepare_form_field( array $form_field ): array {
+		$form_field['options'] = array_column( $form_field['options'], 'label', 'value' );
+
+		if ( ! empty( $form_field['placeholder'] ) && ! array_key_exists( '', $form_field['options'] ) ) {
+			$form_field['options'] = array( '' => $form_field['placeholder'] ) + $form_field['options'];
+		}
+
+		return $form_field;
+	}
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/TextFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/TextFieldType.php
new file mode 100644
index 00000000000..d6de4f9b12f
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/TextFieldType.php
@@ -0,0 +1,12 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes;
+
+/**
+ * The "text" additional checkout field type.
+ *
+ * Text fields have no type-specific options or constraints, so the AbstractFieldType defaults apply.
+ */
+class TextFieldType extends AbstractFieldType {
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
index aa93f77127b..0b2221db759 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
@@ -5,10 +5,12 @@ namespace Automattic\WooCommerce\Blocks\Domain\Services;

 use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
 use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
-use Automattic\WooCommerce\Utilities\TimeUtil;
 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\{
 	DocumentObject, Validation
 };
+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes\{
+	AbstractFieldType, CheckboxFieldType, DateFieldType, SelectFieldType, TextFieldType
+};
 use WC_Customer;
 use WC_Data;
 use WC_Order;
@@ -19,6 +21,8 @@ use WP_Error;
  */
 class CheckoutFields {

+	use CheckoutFieldsStorage;
+
 	/**
 	 * Additional checkout fields.
 	 *
@@ -34,11 +38,11 @@ class CheckoutFields {
 	private $fields_locations;

 	/**
-	 * Supported field types
+	 * Supported field types, keyed by their type slug.
 	 *
-	 * @var array
+	 * @var array<string, AbstractFieldType>
 	 */
-	private $supported_field_types = [ 'text', 'select', 'checkbox', 'date' ];
+	private $field_types = [];

 	/**
 	 * Groups of fields to be saved.
@@ -90,7 +94,15 @@ class CheckoutFields {
 	 */
 	public function __construct( AssetDataRegistry $asset_data_registry ) {
 		$this->asset_data_registry = $asset_data_registry;
-		$this->fields_locations    = [
+
+		$this->field_types = [
+			'text'     => new TextFieldType(),
+			'select'   => new SelectFieldType(),
+			'checkbox' => new CheckboxFieldType(),
+			'date'     => new DateFieldType(),
+		];
+
+		$this->fields_locations = [
 			// omit email from shipping and billing fields.
 			'address' => array_merge( \array_diff_key( $this->get_core_fields_keys(), array( 'email' ) ) ),
 			'contact' => array( 'email' ),
@@ -154,36 +166,37 @@ class CheckoutFields {
 	/**
 	 * If a field does not declare a sanitization callback, this is the default sanitization callback.
 	 *
+	 * @deprecated 11.2.0 Fields are wired to their field type's sanitize() method instead.
+	 *
 	 * @param mixed $value Value to sanitize.
 	 * @param array $field Field data.
 	 * @return mixed
 	 */
 	public function default_sanitize_callback( $value, $field ) {
-		if ( 'date' === ( $field['type'] ?? '' ) && is_string( $value ) ) {
-			return trim( $value );
-		}
+		return $this->get_field_type( $field )->sanitize( $value, $field );
+	}

-		return $value;
+	/**
+	 * Returns the field type handling a field, falling back to text for core and unknown types.
+	 *
+	 * @param array $field The field, or the options supplied during field registration.
+	 * @return AbstractFieldType
+	 */
+	private function get_field_type( array $field ): AbstractFieldType {
+		return $this->field_types[ $field['type'] ?? '' ] ?? $this->field_types['text'];
 	}

 	/**
 	 * If a field does not declare a validation callback, this is the default validation callback.
 	 *
+	 * @deprecated 11.2.0 Fields are wired to their field type's default_validate() method instead.
+	 *
 	 * @param mixed $value Value to sanitize.
 	 * @param array $field Field data.
 	 * @return WP_Error|void If there is a validation error, return an WP_Error object.
 	 */
 	public function default_validate_callback( $value, $field ) {
-		if ( true === $field['required'] && empty( $value ) ) {
-			return new WP_Error(
-				'woocommerce_required_checkout_field',
-				sprintf(
-					// translators: %s is field key.
-					__( 'The field %s is required.', 'woocommerce' ),
-					$field['id']
-				)
-			);
-		}
+		return $this->get_field_type( $field )->default_validate( $value, $field );
 	}

 	/**
@@ -204,6 +217,8 @@ class CheckoutFields {
 			return;
 		}

+		$field_type = $this->get_field_type( $options );
+
 		// The above validate_options function ensures these options are valid. Type might not be supplied but then it defaults to text.
 		$field_data = wp_parse_args(
 			$options,
@@ -218,14 +233,13 @@ class CheckoutFields {
 				'required'                   => false,
 				'attributes'                 => [],
 				'show_in_order_confirmation' => true,
-				'sanitize_callback'          => array( $this, 'default_sanitize_callback' ),
-				'validate_callback'          => array( $this, 'default_validate_callback' ),
+				'sanitize_callback'          => array( $field_type, 'sanitize' ),
+				'validate_callback'          => array( $field_type, 'default_validate' ),
 				'validation'                 => [],
 			],
 		);

-		$field_data['attributes'] = $this->register_field_attributes( $field_data['id'], $field_data['attributes'] );
-		$field_data               = $this->process_field_options( $field_data, $options );
+		$field_data = $this->process_field_options( $field_data, $options );

 		// $field_data will be false if an error that will prevent the field being registered is encountered.
 		if ( false === $field_data ) {
@@ -400,12 +414,6 @@ class CheckoutFields {
 			return false;
 		}

-		if ( empty( $options['label'] ) ) {
-			$message = sprintf( 'Unable to register field with id: "%s". %s', $options['id'], 'The field label is required.' );
-			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
-			return false;
-		}
-
 		if ( empty( $options['location'] ) ) {
 			$message = sprintf( 'Unable to register field with id: "%s". %s', $options['id'], 'The field location is required.' );
 			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
@@ -434,58 +442,18 @@ class CheckoutFields {
 			return false;
 		}

-		if ( ! empty( $options['type'] ) ) {
-			if ( ! in_array( $options['type'], $this->supported_field_types, true ) ) {
-				$message = sprintf(
-					'Unable to register field with id: "%s". Registering a field with type "%s" is not supported. The supported types are: %s.',
-					$id,
-					$options['type'],
-					implode( ', ', $this->supported_field_types )
-				);
-				_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
-				return false;
-			}
-		}
-
-		if ( ! empty( $options['sanitize_callback'] ) && ! is_callable( $options['sanitize_callback'] ) ) {
-			$message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'The sanitize_callback must be a valid callback.' );
-			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
-			return false;
-		}
-
-		if ( ! empty( $options['validate_callback'] ) && ! is_callable( $options['validate_callback'] ) ) {
-			$message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'The validate_callback must be a valid callback.' );
+		if ( ! empty( $options['type'] ) && ! isset( $this->field_types[ $options['type'] ] ) ) {
+			$message = sprintf(
+				'Unable to register field with id: "%s". Registering a field with type "%s" is not supported. The supported types are: %s.',
+				$id,
+				$options['type'],
+				implode( ', ', array_keys( $this->field_types ) )
+			);
 			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
 			return false;
 		}

-		if ( ! empty( $options['hidden'] ) && true === $options['hidden'] ) {
-			// Hidden fields are not supported right now. They will be registered with hidden => false.
-			$message = sprintf( 'Registering a field with hidden set to true is not supported. The field "%s" will be registered as visible.', $id );
-			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
-			// Don't return here unlike the other fields because this is not an issue that will prevent registration.
-		}
-
-		$rule_fields = [ 'required', 'hidden', 'validation' ];
-		$allow_bool  = [ 'required', 'hidden' ];
-
-		foreach ( $rule_fields as $rule_field ) {
-			if ( ! empty( $options[ $rule_field ] ) ) {
-				if ( in_array( $rule_field, $allow_bool, true ) && is_bool( $options[ $rule_field ] ) ) {
-					continue;
-				}
-
-				$valid = Validation::is_valid_schema( $options[ $rule_field ] );
-
-				if ( is_wp_error( $valid ) ) {
-					$message = sprintf( 'Unable to register field with id: "%s". %s', $options['id'], $rule_field . ': ' . $valid->get_error_message() );
-					_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
-					return false;
-				}
-			}
-		}
-
-		return true;
+		return $this->get_field_type( $options )->validate_options( $options );
 	}

 	/**
@@ -493,156 +461,10 @@ class CheckoutFields {
 	 *
 	 * @param array $field_data The field data array to be updated.
 	 * @param array $options    The options supplied during field registration.
-	 * @return array The updated $field_data array.
+	 * @return array|false The updated $field_data array, or false if an error should prevent registration.
 	 */
 	private function process_field_options( $field_data, $options ) {
-		if ( 'checkbox' === $field_data['type'] ) {
-			$field_data = $this->process_checkbox_field( $field_data, $options );
-		} elseif ( 'select' === $field_data['type'] ) {
-			$field_data = $this->process_select_field( $field_data, $options );
-		}
-		return $field_data;
-	}
-
-	/**
-	 * Processes the options for a select field and returns the new field_options array.
-	 *
-	 * @param array $field_data  The field data array to be updated.
-	 * @param array $options     The options supplied during field registration.
-	 *
-	 * @return array|false The updated $field_data array or false if an error was encountered.
-	 */
-	private function process_select_field( $field_data, $options ) {
-		$id = $options['id'];
-
-		if ( empty( $options['options'] ) || ! is_array( $options['options'] ) ) {
-			$message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'Fields of type "select" must have an array of "options".' );
-			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
-			return false;
-		}
-		$cleaned_options = [];
-		$added_values    = [];
-
-		// Check all entries in $options['options'] has a key and value member.
-		foreach ( $options['options'] as $option ) {
-			if ( ! isset( $option['value'] ) || ! isset( $option['label'] ) ) {
-				$message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'Fields of type "select" must have an array of "options" and each option must contain a "value" and "label" member.' );
-				_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
-				return false;
-			}
-
-			$sanitized_value = sanitize_text_field( $option['value'] );
-			$sanitized_label = sanitize_text_field( $option['label'] );
-
-			if ( in_array( $sanitized_value, $added_values, true ) ) {
-				$message = sprintf( 'Duplicate key found when registering field with id: "%s". The value in each option of "select" fields must be unique. Duplicate value "%s" found. The duplicate key will be removed.', $id, $sanitized_value );
-				_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
-				continue;
-			}
-
-			$added_values[] = $sanitized_value;
-
-			$cleaned_options[] = [
-				'value' => $sanitized_value,
-				'label' => $sanitized_label,
-			];
-		}
-
-		$field_data['options'] = $cleaned_options;
-
-		if ( isset( $field_data['placeholder'] ) ) {
-			$field_data['placeholder'] = sanitize_text_field( $field_data['placeholder'] );
-		}
-
-		return $field_data;
-	}
-
-	/**
-	 * Processes the options for a checkbox field and returns the new field_options array.
-	 *
-	 * @param array $field_data  The field data array to be updated.
-	 * @param array $options     The options supplied during field registration.
-	 *
-	 * @return array|false The updated $field_data array or false if an error was encountered.
-	 */
-	private function process_checkbox_field( $field_data, $options ) {
-		$id                     = $options['id'];
-		$field_data['required'] = $options['required'] ?? false;
-
-		if ( false === $field_data['required'] && ! empty( $options['error_message'] ) ) {
-			$message = sprintf( 'Passing an error message to a non-required checkbox "%s" will have no effect. The error message has been removed from the field.', $id );
-			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '9.8.0' );
-			unset( $field_data['error_message'] );
-		}
-
-		if ( isset( $options['error_message'] ) && ! is_string( $options['error_message'] ) ) {
-			$message = sprintf( 'The error_message property for field with id: "%s" must be a string, you passed %s. A default message will be shown.', $id, gettype( $options['error_message'] ) );
-			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '9.8.0' );
-			unset( $field_data['error_message'] );
-		}
-
-		// Get the error message property and set it to errorMessage for use in JS.
-		if ( isset( $field_data['error_message'] ) ) {
-			$field_data['errorMessage'] = $field_data['error_message'];
-			unset( $field_data['error_message'] );
-		}
-
-		return $field_data;
-	}
-
-	/**
-	 * Processes the attributes supplied during field registration.
-	 *
-	 * @param array $id         The field ID.
-	 * @param array $attributes The attributes supplied during field registration.
-	 *
-	 * @return array The processed attributes.
-	 */
-	private function register_field_attributes( $id, $attributes ) {
-		// We check if attributes are valid. This is done to prevent too much nesting and also to allow field registration
-		// even if the attributes property is invalid. We can just skip it and register the field without attributes.
-		if ( empty( $attributes ) ) {
-			return [];
-		}
-
-		if ( ! is_array( $attributes ) || 0 === count( $attributes ) ) {
-			$message = sprintf( 'An invalid attributes value was supplied when registering field with id: "%s". %s', $id, 'Attributes must be a non-empty array.' );
-			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
-			return [];
-		}
-
-		// These are formatted in camelCase because React components expect them that way.
-		$allowed_attributes = [
-			'maxLength',
-			'readOnly',
-			'pattern',
-			'autocomplete',
-			'autocapitalize',
-			'title',
-		];
-
-		$valid_attributes = array_filter(
-			$attributes,
-			function ( $_, $key ) use ( $allowed_attributes ) {
-				return in_array( $key, $allowed_attributes, true ) || strpos( $key, 'aria-' ) === 0 || strpos( $key, 'data-' ) === 0;
-			},
-			ARRAY_FILTER_USE_BOTH
-		);
-
-		// Any invalid attributes should show a doing_it_wrong warning. It shouldn't stop field registration, though.
-		if ( count( $attributes ) !== count( $valid_attributes ) ) {
-			$invalid_attributes = array_keys( array_diff_key( $attributes, $valid_attributes ) );
-			$message            = sprintf( 'Invalid attribute found when registering field with id: "%s". Attributes: %s are not allowed.', $id, implode( ', ', $invalid_attributes ) );
-			_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
-		}
-
-		// Escape attributes to remove any malicious code and return them.
-		return array_map(
-			function ( $value ) {
-				return esc_attr( $value );
-			},
-			$valid_attributes
-		);
+		return $this->get_field_type( $field_data )->process_options( $field_data, $options );
 	}

 	/**
@@ -651,19 +473,7 @@ class CheckoutFields {
 	 * @return array An array of field keys.
 	 */
 	public function get_core_fields_keys() {
-		return [
-			'email',
-			'country',
-			'first_name',
-			'last_name',
-			'company',
-			'address_1',
-			'address_2',
-			'city',
-			'state',
-			'postcode',
-			'phone',
-		];
+		return CoreCheckoutFields::get_keys();
 	}

 	/**
@@ -672,141 +482,7 @@ class CheckoutFields {
 	 * @return array An array of fields.
 	 */
 	public function get_core_fields() {
-		return [
-			'email'      => [
-				'label'          => __( 'Email address', 'woocommerce' ),
-				'optionalLabel'  => __(
-					'Email address (optional)',
-					'woocommerce'
-				),
-				'required'       => true,
-				'hidden'         => false,
-				'autocomplete'   => 'email',
-				'autocapitalize' => 'none',
-				'type'           => 'email',
-				'index'          => 0,
-			],
-			'country'    => [
-				'label'         => __( 'Country/Region', 'woocommerce' ),
-				'optionalLabel' => __(
-					'Country/Region (optional)',
-					'woocommerce'
-				),
-				'required'      => true,
-				'hidden'        => false,
-				'autocomplete'  => 'country',
-				'index'         => 1,
-			],
-			'first_name' => [
-				'label'          => __( 'First name', 'woocommerce' ),
-				'optionalLabel'  => __(
-					'First name (optional)',
-					'woocommerce'
-				),
-				'required'       => true,
-				'hidden'         => false,
-				'autocomplete'   => 'given-name',
-				'autocapitalize' => 'sentences',
-				'index'          => 10,
-			],
-			'last_name'  => [
-				'label'          => __( 'Last name', 'woocommerce' ),
-				'optionalLabel'  => __(
-					'Last name (optional)',
-					'woocommerce'
-				),
-				'required'       => true,
-				'hidden'         => false,
-				'autocomplete'   => 'family-name',
-				'autocapitalize' => 'sentences',
-				'index'          => 20,
-			],
-			'company'    => [
-				'label'          => __( 'Company', 'woocommerce' ),
-				'optionalLabel'  => __(
-					'Company (optional)',
-					'woocommerce'
-				),
-				'required'       => 'required' === CartCheckoutUtils::get_company_field_visibility(),
-				'hidden'         => 'hidden' === CartCheckoutUtils::get_company_field_visibility(),
-				'autocomplete'   => 'organization',
-				'autocapitalize' => 'sentences',
-				'index'          => 30,
-			],
-			'address_1'  => [
-				'label'          => __( 'Address', 'woocommerce' ),
-				'optionalLabel'  => __(
-					'Address (optional)',
-					'woocommerce'
-				),
-				'required'       => true,
-				'hidden'         => false,
-				'autocomplete'   => 'address-line1',
-				'autocapitalize' => 'sentences',
-				'index'          => 40,
-			],
-			'address_2'  => [
-				'label'          => __( 'Apartment, suite, etc.', 'woocommerce' ),
-				'optionalLabel'  => __(
-					'Apartment, suite, etc. (optional)',
-					'woocommerce'
-				),
-				'required'       => 'required' === CartCheckoutUtils::get_address_2_field_visibility(),
-				'hidden'         => 'hidden' === CartCheckoutUtils::get_address_2_field_visibility(),
-				'autocomplete'   => 'address-line2',
-				'autocapitalize' => 'sentences',
-				'index'          => 50,
-			],
-			'city'       => [
-				'label'          => __( 'City', 'woocommerce' ),
-				'optionalLabel'  => __(
-					'City (optional)',
-					'woocommerce'
-				),
-				'required'       => true,
-				'hidden'         => false,
-				'autocomplete'   => 'address-level2',
-				'autocapitalize' => 'sentences',
-				'index'          => 70,
-			],
-			'state'      => [
-				'label'          => __( 'State/County', 'woocommerce' ),
-				'optionalLabel'  => __(
-					'State/County (optional)',
-					'woocommerce'
-				),
-				'required'       => true,
-				'hidden'         => false,
-				'autocomplete'   => 'address-level1',
-				'autocapitalize' => 'sentences',
-				'index'          => 80,
-			],
-			'postcode'   => [
-				'label'          => __( 'Postal code', 'woocommerce' ),
-				'optionalLabel'  => __(
-					'Postal code (optional)',
-					'woocommerce'
-				),
-				'required'       => true,
-				'hidden'         => false,
-				'autocomplete'   => 'postal-code',
-				'autocapitalize' => 'characters',
-				'index'          => 90,
-			],
-			'phone'      => [
-				'label'          => __( 'Phone', 'woocommerce' ),
-				'optionalLabel'  => __(
-					'Phone (optional)',
-					'woocommerce'
-				),
-				'required'       => 'required' === CartCheckoutUtils::get_phone_field_visibility(),
-				'hidden'         => 'hidden' === CartCheckoutUtils::get_phone_field_visibility(),
-				'type'           => 'tel',
-				'autocomplete'   => 'tel',
-				'autocapitalize' => 'characters',
-				'index'          => 100,
-			],
-		];
+		return CoreCheckoutFields::get_fields();
 	}

 	/**
@@ -901,23 +577,7 @@ class CheckoutFields {
 	 * @return WP_Error|null Error if the value is not valid for the field type, null otherwise.
 	 */
 	private function validate_field_type( $field, $field_value ) {
-		// An empty value is not a type error. Required fields are handled by the field's validation callback.
-		if ( 'date' !== ( $field['type'] ?? '' ) || null === $field_value || '' === $field_value ) {
-			return null;
-		}
-
-		if ( ! is_string( $field_value ) || ! TimeUtil::is_valid_date( $field_value, 'Y-m-d' ) ) {
-			return new WP_Error(
-				'woocommerce_invalid_checkout_field',
-				sprintf(
-					/* translators: %s: is the field label */
-					__( 'Please provide a valid %s in YYYY-MM-DD format.', 'woocommerce' ),
-					$field['label']
-				)
-			);
-		}
-
-		return null;
+		return $this->get_field_type( $field )->validate( $field_value, $field );
 	}

 	/**
@@ -1215,196 +875,6 @@ class CheckoutFields {
 		return in_array( $key, array_intersect( array_merge( $this->get_address_fields_keys(), $this->get_contact_fields_keys() ), array_keys( $this->additional_fields ) ), true );
 	}

-	/**
-	 * Persists a field value for a given order. This would also optionally set the field value on the customer object if the order is linked to a registered customer.
-	 *
-	 * @param string   $key The field key.
-	 * @param mixed    $value The field value.
-	 * @param WC_Order $order The order to persist the field for.
-	 * @param string   $group The group to persist the field for (shipping|billing|other).
-	 * @param bool     $set_customer Whether to set the field value on the customer or not.
-	 *
-	 * @return void
-	 */
-	public function persist_field_for_order( string $key, $value, WC_Order $order, string $group = 'other', bool $set_customer = true ) {
-		$group = $this->prepare_group_name( $group );
-		$this->set_array_meta( $key, $value, $order, $group );
-		if ( $set_customer && $order->get_customer_id() ) {
-			$customer = new WC_Customer( $order->get_customer_id() );
-			$this->persist_field_for_customer( $key, $value, $customer, $group );
-		}
-	}
-
-	/**
-	 * Persists a field value for a given customer.
-	 *
-	 * @param string      $key The field key.
-	 * @param mixed       $value The field value.
-	 * @param WC_Customer $customer The customer to persist the field for.
-	 * @param string      $group The group to persist the field for (shipping|billing|other).
-	 *
-	 * @return void
-	 */
-	public function persist_field_for_customer( string $key, $value, WC_Customer $customer, string $group = 'other' ) {
-		$group = $this->prepare_group_name( $group );
-		$this->set_array_meta( $key, $value, $customer, $group );
-	}
-
-	/**
-	 * Sets a field value in an array meta, supporting routing things to billing, shipping, or additional fields, based on a prefix for the key.
-	 *
-	 * @param string               $key The field key.
-	 * @param mixed                $value The field value.
-	 * @param WC_Customer|WC_Order $wc_object The object to set the field value for.
-	 * @param string               $group The group to set the field value for (shipping|billing|other).
-	 *
-	 * @return void
-	 */
-	private function set_array_meta( string $key, $value, WC_Data $wc_object, string $group ) {
-		$meta_key = self::get_group_key( $group ) . $key;
-
-		/**
-		 * Allow reacting for saving an additional field value.
-		 *
-		 * @param string               $key The key of the field being saved.
-		 * @param mixed                $value The value of the field being saved.
-		 * @param string               $group The group of this location (shipping|billing|other).
-		 * @param WC_Customer|WC_Order $wc_object The object to set the field value for.
-		 *
-		 * @since 8.9.0
-		 */
-		do_action( 'woocommerce_set_additional_field_value', $key, $value, $group, $wc_object );
-		// Convert boolean values to strings because Data Stores will skip false values.
-		if ( is_bool( $value ) ) {
-			$value = $value ? '1' : '0';
-		}
-		$wc_object->update_meta_data( $meta_key, $value );
-	}
-
-	/**
-	 * Returns a field value for a given object.
-	 *
-	 * @param string               $key The field key.
-	 * @param WC_Customer|WC_Order $wc_object The customer or order to get the field value for.
-	 * @param string               $group The group to get the field value for (shipping|billing|other).
-	 *
-	 * @return mixed The field value.
-	 */
-	public function get_field_from_object( string $key, WC_Data $wc_object, string $group = 'other' ) {
-		$group    = $this->prepare_group_name( $group );
-		$meta_key = self::get_group_key( $group ) . $key;
-		$value    = $wc_object->get_meta( $meta_key, true );
-
-		if ( ! $value && '0' !== $value ) {
-			/**
-			 * Allow providing a default value for additional fields if no value is already set.
-			 *
-			 * @param null $value The default value for the filter, always null.
-			 * @param string $group The group of this key (shipping|billing|other).
-			 * @param WC_Data $wc_object The object to get the field value for.
-			 *
-			 * @since 8.9.0
-			 */
-			$value = apply_filters( "woocommerce_get_default_value_for_{$key}", null, $group, $wc_object );
-		}
-
-		// We cast the value to a boolean if the field is a checkbox.
-		if ( $this->is_field( $key ) && 'checkbox' === $this->additional_fields[ $key ]['type'] ) {
-			return '1' === $value;
-		}
-
-		if ( null === $value ) {
-			return '';
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Returns an array of all fields values for a given object in a group.
-	 *
-	 * @param WC_Data $wc_object The object or order to get the fields for.
-	 * @param string  $group The group to get the fields for (shipping|billing|other).
-	 * @param bool    $all Whether to return all fields or only the ones that are still registered. Default false.
-	 * @return array An array of fields.
-	 */
-	public function get_all_fields_from_object( WC_Data $wc_object, string $group = 'other', bool $all = false ) {
-		$meta_data = [];
-		$group     = $this->prepare_group_name( $group );
-		$prefix    = self::get_group_key( $group );
-
-		if ( $wc_object instanceof WC_Data ) {
-			$meta = $wc_object->get_meta_data();
-			foreach ( $meta as $meta_data_object ) {
-				if ( 0 === \strpos( $meta_data_object->key, $prefix ) ) {
-					$key = \str_replace( $prefix, '', $meta_data_object->key );
-					if ( $all || $this->is_field( $key ) ) {
-						$meta_data[ $key ] = $meta_data_object->value;
-					}
-				}
-			}
-		}
-
-		$missing_fields = array_diff( array_keys( $this->get_fields_for_group( $group ) ), array_keys( $meta_data ) );
-
-		foreach ( $missing_fields as $missing_field ) {
-				/**
-				 * Allow providing a default value for additional fields if no value is already set.
-				 *
-				 * @param null $value The default value for the filter, always null.
-				 * @param string $group The group of this key (shipping|billing|other).
-				 * @param WC_Data $wc_object The object to get the field value for.
-				 *
-				 * @since 8.9.0
-				 */
-				$value = apply_filters( "woocommerce_get_default_value_for_{$missing_field}", null, $group, $wc_object );
-
-			if ( isset( $value ) ) {
-				$meta_data[ $missing_field ] = $value;
-			}
-		}
-
-		return $meta_data;
-	}
-
-	/**
-	 * Copies additional fields from an order to a customer.
-	 *
-	 * @param WC_Order    $order The order to sync the fields for.
-	 * @param WC_Customer $customer The customer to sync the fields for.
-	 */
-	public function sync_customer_additional_fields_with_order( WC_Order $order, WC_Customer $customer ) {
-		foreach ( $this->groups as $group ) {
-			$order_additional_fields = $this->get_all_fields_from_object( $order, $group, true );
-
-			// Sync customer additional fields with order additional fields.
-			foreach ( $order_additional_fields as $key => $value ) {
-				if ( $this->is_customer_field( $key ) ) {
-					$this->persist_field_for_customer( $key, $value, $customer, $group );
-				}
-			}
-		}
-	}
-
-	/**
-	 * Copies additional fields from a customer to an order.
-	 *
-	 * @param WC_Order    $order The order to sync the fields for.
-	 * @param WC_Customer $customer The customer to sync the fields for.
-	 */
-	public function sync_order_additional_fields_with_customer( WC_Order $order, WC_Customer $customer ) {
-		foreach ( $this->groups as $group ) {
-			$customer_additional_fields = $this->get_all_fields_from_object( $customer, $group, true );
-
-			// Sync order additional fields with customer additional fields.
-			foreach ( $customer_additional_fields as $key => $value ) {
-				if ( $this->is_field( $key ) ) {
-					$this->persist_field_for_order( $key, $value, $order, $group, false );
-				}
-			}
-		}
-	}
-
 	/**
 	 * From a set of fields, returns only the ones for a given location.
 	 *
@@ -1509,25 +979,19 @@ class CheckoutFields {
 	 * @return string
 	 */
 	public function format_additional_field_value( $value, $field ) {
-		if ( 'checkbox' === $field['type'] ) {
-			$value = $value ? __( 'Yes', 'woocommerce' ) : __( 'No', 'woocommerce' );
-		}
-
-		if ( 'select' === $field['type'] ) {
-			$options = array_column( $field['options'], 'label', 'value' );
-			$value   = isset( $options[ $value ] ) ? $options[ $value ] : $value;
-		}
-
-		if ( 'date' === $field['type'] && is_string( $value ) && TimeUtil::is_valid_date( $value, 'Y-m-d' ) ) {
-			// Parsed in the site timezone so the stored calendar date cannot shift a day when it is formatted.
-			$date = \DateTime::createFromFormat( '!Y-m-d', $value, wp_timezone() );
-
-			if ( $date ) {
-				$value = wp_date( wc_date_format(), $date->getTimestamp() );
-			}
-		}
+		return $this->get_field_type( $field )->format_value( $value, $field );
+	}

-		return $value;
+	/**
+	 * Applies type-specific arguments to a field before it is rendered with woocommerce_form_field().
+	 *
+	 * Used by the server-rendered My Account forms: maps select options and sets checkbox submit values.
+	 *
+	 * @param array $form_field The woocommerce_form_field() arguments built from the field.
+	 * @return array The updated arguments.
+	 */
+	public function prepare_form_field( array $form_field ): array {
+		return $this->get_field_type( $form_field )->prepare_form_field( $form_field );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
index 4e2c3a2d2e9..5ff7f251fc5 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
@@ -172,14 +172,7 @@ class CheckoutFieldsFrontend {
 			$form_field['id']    = $field_key;
 			$form_field['value'] = $this->checkout_fields_controller->get_field_from_object( $key, $customer, 'contact' );

-			if ( 'select' === $field['type'] ) {
-				$form_field['options'] = array_column( $field['options'], 'label', 'value' );
-			}
-
-			if ( 'checkbox' === $field['type'] ) {
-				$form_field['checked_value']   = '1';
-				$form_field['unchecked_value'] = '0';
-			}
+			$form_field = $this->checkout_fields_controller->prepare_form_field( $form_field );

 			woocommerce_form_field( $field_key, $form_field, wc_get_post_data_by_key( $key, $form_field['value'] ) );
 		}
@@ -205,22 +198,7 @@ class CheckoutFieldsFrontend {
 			$address[ $field_key ]          = $field;
 			$address[ $field_key ]['value'] = $this->checkout_fields_controller->get_field_from_object( $key, $customer, $address_type );

-			if ( 'select' === $field['type'] ) {
-				$address[ $field_key ]['options'] = array_column( $field['options'], 'label', 'value' );
-
-				// If a placeholder is set, add a placeholder option if it doesn't exist already.
-				if (
-					! empty( $address[ $field_key ]['placeholder'] )
-					&& ! array_key_exists( '', $address[ $field_key ]['options'] )
-				) {
-					$address[ $field_key ]['options'] = array( '' => $address[ $field_key ]['placeholder'] ) + $address[ $field_key ]['options'];
-				}
-			}
-
-			if ( 'checkbox' === $field['type'] ) {
-				$address[ $field_key ]['checked_value']   = '1';
-				$address[ $field_key ]['unchecked_value'] = '0';
-			}
+			$address[ $field_key ] = $this->checkout_fields_controller->prepare_form_field( $address[ $field_key ] );
 		}

 		return $address;
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php
new file mode 100644
index 00000000000..fca1363808f
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php
@@ -0,0 +1,209 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services;
+
+use WC_Customer;
+use WC_Data;
+use WC_Order;
+
+/**
+ * Reads, writes, and syncs additional checkout field values stored as order and customer meta.
+ *
+ * Used by CheckoutFields, which owns the field definitions and validation; this trait only covers
+ * where values are persisted and how they are read back.
+ */
+trait CheckoutFieldsStorage {
+
+	/**
+	 * Persists a field value for a given order. This would also optionally set the field value on the customer object if the order is linked to a registered customer.
+	 *
+	 * @param string   $key The field key.
+	 * @param mixed    $value The field value.
+	 * @param WC_Order $order The order to persist the field for.
+	 * @param string   $group The group to persist the field for (shipping|billing|other).
+	 * @param bool     $set_customer Whether to set the field value on the customer or not.
+	 *
+	 * @return void
+	 */
+	public function persist_field_for_order( string $key, $value, WC_Order $order, string $group = 'other', bool $set_customer = true ) {
+		$group = $this->prepare_group_name( $group );
+		$this->set_array_meta( $key, $value, $order, $group );
+		if ( $set_customer && $order->get_customer_id() ) {
+			$customer = new WC_Customer( $order->get_customer_id() );
+			$this->persist_field_for_customer( $key, $value, $customer, $group );
+		}
+	}
+
+	/**
+	 * Persists a field value for a given customer.
+	 *
+	 * @param string      $key The field key.
+	 * @param mixed       $value The field value.
+	 * @param WC_Customer $customer The customer to persist the field for.
+	 * @param string      $group The group to persist the field for (shipping|billing|other).
+	 *
+	 * @return void
+	 */
+	public function persist_field_for_customer( string $key, $value, WC_Customer $customer, string $group = 'other' ) {
+		$group = $this->prepare_group_name( $group );
+		$this->set_array_meta( $key, $value, $customer, $group );
+	}
+
+	/**
+	 * Sets a field value in an array meta, supporting routing things to billing, shipping, or additional fields, based on a prefix for the key.
+	 *
+	 * @param string               $key The field key.
+	 * @param mixed                $value The field value.
+	 * @param WC_Customer|WC_Order $wc_object The object to set the field value for.
+	 * @param string               $group The group to set the field value for (shipping|billing|other).
+	 *
+	 * @return void
+	 */
+	private function set_array_meta( string $key, $value, WC_Data $wc_object, string $group ) {
+		$meta_key = self::get_group_key( $group ) . $key;
+
+		/**
+		 * Allow reacting for saving an additional field value.
+		 *
+		 * @param string               $key The key of the field being saved.
+		 * @param mixed                $value The value of the field being saved.
+		 * @param string               $group The group of this location (shipping|billing|other).
+		 * @param WC_Customer|WC_Order $wc_object The object to set the field value for.
+		 *
+		 * @since 8.9.0
+		 */
+		do_action( 'woocommerce_set_additional_field_value', $key, $value, $group, $wc_object );
+		// Convert boolean values to strings because Data Stores will skip false values.
+		if ( is_bool( $value ) ) {
+			$value = $value ? '1' : '0';
+		}
+		$wc_object->update_meta_data( $meta_key, $value );
+	}
+
+	/**
+	 * Returns a field value for a given object.
+	 *
+	 * @param string               $key The field key.
+	 * @param WC_Customer|WC_Order $wc_object The customer or order to get the field value for.
+	 * @param string               $group The group to get the field value for (shipping|billing|other).
+	 *
+	 * @return mixed The field value.
+	 */
+	public function get_field_from_object( string $key, WC_Data $wc_object, string $group = 'other' ) {
+		$group    = $this->prepare_group_name( $group );
+		$meta_key = self::get_group_key( $group ) . $key;
+		$value    = $wc_object->get_meta( $meta_key, true );
+
+		if ( ! $value && '0' !== $value ) {
+			/**
+			 * Allow providing a default value for additional fields if no value is already set.
+			 *
+			 * @param null $value The default value for the filter, always null.
+			 * @param string $group The group of this key (shipping|billing|other).
+			 * @param WC_Data $wc_object The object to get the field value for.
+			 *
+			 * @since 8.9.0
+			 */
+			$value = apply_filters( "woocommerce_get_default_value_for_{$key}", null, $group, $wc_object );
+		}
+
+		// We cast the value to a boolean if the field is a checkbox.
+		if ( $this->is_field( $key ) && 'checkbox' === $this->additional_fields[ $key ]['type'] ) {
+			return '1' === $value;
+		}
+
+		if ( null === $value ) {
+			return '';
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Returns an array of all fields values for a given object in a group.
+	 *
+	 * @param WC_Data $wc_object The object or order to get the fields for.
+	 * @param string  $group The group to get the fields for (shipping|billing|other).
+	 * @param bool    $all Whether to return all fields or only the ones that are still registered. Default false.
+	 * @return array An array of fields.
+	 */
+	public function get_all_fields_from_object( WC_Data $wc_object, string $group = 'other', bool $all = false ) {
+		$meta_data = [];
+		$group     = $this->prepare_group_name( $group );
+		$prefix    = self::get_group_key( $group );
+
+		$meta = $wc_object->get_meta_data();
+		foreach ( $meta as $meta_data_object ) {
+			if ( 0 === \strpos( $meta_data_object->key, $prefix ) ) {
+				$key = \str_replace( $prefix, '', $meta_data_object->key );
+				if ( $all || $this->is_field( $key ) ) {
+					$meta_data[ $key ] = $meta_data_object->value;
+				}
+			}
+		}
+
+		$missing_fields = array_diff( array_keys( $this->get_fields_for_group( $group ) ), array_keys( $meta_data ) );
+
+		foreach ( $missing_fields as $missing_field ) {
+				/**
+				 * Allow providing a default value for additional fields if no value is already set.
+				 *
+				 * @param null $value The default value for the filter, always null.
+				 * @param string $group The group of this key (shipping|billing|other).
+				 * @param WC_Data $wc_object The object to get the field value for.
+				 *
+				 * @since 8.9.0
+				 */
+				$value = apply_filters( "woocommerce_get_default_value_for_{$missing_field}", null, $group, $wc_object );
+
+			if ( isset( $value ) ) {
+				$meta_data[ $missing_field ] = $value;
+			}
+		}
+
+		return $meta_data;
+	}
+
+	/**
+	 * Copies additional fields from an order to a customer.
+	 *
+	 * @param WC_Order    $order The order to sync the fields for.
+	 * @param WC_Customer $customer The customer to sync the fields for.
+	 *
+	 * @return void
+	 */
+	public function sync_customer_additional_fields_with_order( WC_Order $order, WC_Customer $customer ) {
+		foreach ( $this->groups as $group ) {
+			$order_additional_fields = $this->get_all_fields_from_object( $order, $group, true );
+
+			// Sync customer additional fields with order additional fields.
+			foreach ( $order_additional_fields as $key => $value ) {
+				if ( $this->is_customer_field( $key ) ) {
+					$this->persist_field_for_customer( $key, $value, $customer, $group );
+				}
+			}
+		}
+	}
+
+	/**
+	 * Copies additional fields from a customer to an order.
+	 *
+	 * @param WC_Order    $order The order to sync the fields for.
+	 * @param WC_Customer $customer The customer to sync the fields for.
+	 *
+	 * @return void
+	 */
+	public function sync_order_additional_fields_with_customer( WC_Order $order, WC_Customer $customer ) {
+		foreach ( $this->groups as $group ) {
+			$customer_additional_fields = $this->get_all_fields_from_object( $customer, $group, true );
+
+			// Sync order additional fields with customer additional fields.
+			foreach ( $customer_additional_fields as $key => $value ) {
+				if ( $this->is_field( $key ) ) {
+					$this->persist_field_for_order( $key, $value, $order, $group, false );
+				}
+			}
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CoreCheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CoreCheckoutFields.php
new file mode 100644
index 00000000000..6debbc199d2
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CoreCheckoutFields.php
@@ -0,0 +1,176 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services;
+
+use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
+
+/**
+ * Defines the core checkout fields: the address, contact, and order fields every store starts with.
+ */
+class CoreCheckoutFields {
+
+	/**
+	 * Returns the keys of all core fields.
+	 *
+	 * @return array An array of field keys.
+	 */
+	public static function get_keys() {
+		return [
+			'email',
+			'country',
+			'first_name',
+			'last_name',
+			'company',
+			'address_1',
+			'address_2',
+			'city',
+			'state',
+			'postcode',
+			'phone',
+		];
+	}
+
+	/**
+	 * Returns an array of all core fields.
+	 *
+	 * @return array An array of fields.
+	 */
+	public static function get_fields() {
+		return [
+			'email'      => [
+				'label'          => __( 'Email address', 'woocommerce' ),
+				'optionalLabel'  => __(
+					'Email address (optional)',
+					'woocommerce'
+				),
+				'required'       => true,
+				'hidden'         => false,
+				'autocomplete'   => 'email',
+				'autocapitalize' => 'none',
+				'type'           => 'email',
+				'index'          => 0,
+			],
+			'country'    => [
+				'label'         => __( 'Country/Region', 'woocommerce' ),
+				'optionalLabel' => __(
+					'Country/Region (optional)',
+					'woocommerce'
+				),
+				'required'      => true,
+				'hidden'        => false,
+				'autocomplete'  => 'country',
+				'index'         => 1,
+			],
+			'first_name' => [
+				'label'          => __( 'First name', 'woocommerce' ),
+				'optionalLabel'  => __(
+					'First name (optional)',
+					'woocommerce'
+				),
+				'required'       => true,
+				'hidden'         => false,
+				'autocomplete'   => 'given-name',
+				'autocapitalize' => 'sentences',
+				'index'          => 10,
+			],
+			'last_name'  => [
+				'label'          => __( 'Last name', 'woocommerce' ),
+				'optionalLabel'  => __(
+					'Last name (optional)',
+					'woocommerce'
+				),
+				'required'       => true,
+				'hidden'         => false,
+				'autocomplete'   => 'family-name',
+				'autocapitalize' => 'sentences',
+				'index'          => 20,
+			],
+			'company'    => [
+				'label'          => __( 'Company', 'woocommerce' ),
+				'optionalLabel'  => __(
+					'Company (optional)',
+					'woocommerce'
+				),
+				'required'       => 'required' === CartCheckoutUtils::get_company_field_visibility(),
+				'hidden'         => 'hidden' === CartCheckoutUtils::get_company_field_visibility(),
+				'autocomplete'   => 'organization',
+				'autocapitalize' => 'sentences',
+				'index'          => 30,
+			],
+			'address_1'  => [
+				'label'          => __( 'Address', 'woocommerce' ),
+				'optionalLabel'  => __(
+					'Address (optional)',
+					'woocommerce'
+				),
+				'required'       => true,
+				'hidden'         => false,
+				'autocomplete'   => 'address-line1',
+				'autocapitalize' => 'sentences',
+				'index'          => 40,
+			],
+			'address_2'  => [
+				'label'          => __( 'Apartment, suite, etc.', 'woocommerce' ),
+				'optionalLabel'  => __(
+					'Apartment, suite, etc. (optional)',
+					'woocommerce'
+				),
+				'required'       => 'required' === CartCheckoutUtils::get_address_2_field_visibility(),
+				'hidden'         => 'hidden' === CartCheckoutUtils::get_address_2_field_visibility(),
+				'autocomplete'   => 'address-line2',
+				'autocapitalize' => 'sentences',
+				'index'          => 50,
+			],
+			'city'       => [
+				'label'          => __( 'City', 'woocommerce' ),
+				'optionalLabel'  => __(
+					'City (optional)',
+					'woocommerce'
+				),
+				'required'       => true,
+				'hidden'         => false,
+				'autocomplete'   => 'address-level2',
+				'autocapitalize' => 'sentences',
+				'index'          => 70,
+			],
+			'state'      => [
+				'label'          => __( 'State/County', 'woocommerce' ),
+				'optionalLabel'  => __(
+					'State/County (optional)',
+					'woocommerce'
+				),
+				'required'       => true,
+				'hidden'         => false,
+				'autocomplete'   => 'address-level1',
+				'autocapitalize' => 'sentences',
+				'index'          => 80,
+			],
+			'postcode'   => [
+				'label'          => __( 'Postal code', 'woocommerce' ),
+				'optionalLabel'  => __(
+					'Postal code (optional)',
+					'woocommerce'
+				),
+				'required'       => true,
+				'hidden'         => false,
+				'autocomplete'   => 'postal-code',
+				'autocapitalize' => 'characters',
+				'index'          => 90,
+			],
+			'phone'      => [
+				'label'          => __( 'Phone', 'woocommerce' ),
+				'optionalLabel'  => __(
+					'Phone (optional)',
+					'woocommerce'
+				),
+				'required'       => 'required' === CartCheckoutUtils::get_phone_field_visibility(),
+				'hidden'         => 'hidden' === CartCheckoutUtils::get_phone_field_visibility(),
+				'type'           => 'tel',
+				'autocomplete'   => 'tel',
+				'autocapitalize' => 'characters',
+				'index'          => 100,
+			],
+		];
+	}
+}