Skip to content

Instantly share code, notes, and snippets.

@TermWay
Last active November 29, 2025 18:10
Show Gist options
  • Select an option

  • Save TermWay/2caab22f04e6faa4cc578710451c5750 to your computer and use it in GitHub Desktop.

Select an option

Save TermWay/2caab22f04e6faa4cc578710451c5750 to your computer and use it in GitHub Desktop.
A custom [Required] attribute for Unity that enforces field assignments with Inspector alerts and automatic play-mode validation.
/*
MIT License
Copyright (c) 2025 termway
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
using System;
using System.Reflection;
using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
using Object = UnityEngine.Object;
namespace EditorTools
{
/// <summary>
/// Attribute to mark a field as required.
/// Displays an error box in the Inspector if the field is null (reference types only).
/// </summary>
[AttributeUsage(AttributeTargets.Field, Inherited = true)]
public class RequiredAttribute : PropertyAttribute
{
/// <summary>
/// Custom error message to display when the field is missing.
/// </summary>
public string Message { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="RequiredAttribute"/> class.
/// </summary>
/// <param name="message">Optional custom error message.</param>
public RequiredAttribute(string message = null)
{
Message = message;
}
}
/// <summary>
/// Provides extension methods to validate fields marked with <see cref="RequiredAttribute"/>.
/// </summary>
public static class RequiredAttributeValidator
{
/// <summary>
/// Checks all fields marked with <see cref="RequiredAttribute"/> on the target object.
/// Logs an error to the console for any null required fields.
/// </summary>
/// <param name="target">The MonoBehaviour to validate.</param>
public static void Validate(this MonoBehaviour target)
{
if (target == null)
{
Debug.LogError($"{nameof(RequiredAttributeValidator)}: Target is null.");
return;
}
Type type = target.GetType();
FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
foreach (FieldInfo field in fields)
{
if (Attribute.GetCustomAttribute(field, typeof(RequiredAttribute)) is RequiredAttribute attribute)
{
// Check if the field is a Unity Object type
if (!typeof(Object).IsAssignableFrom(field.FieldType))
{
Debug.LogWarning($"{type.Name}: Field '{field.Name}' uses [Required] but is not a Unity Object (Reference Type). [Required] only works on Unity Objects.", target);
continue;
}
object value = field.GetValue(target);
// Check if the value is a Unity Object and is null (Unity's null check)
bool isNull = value == null || (value is Object unityObject && unityObject == null);
if (isNull)
{
string path = GetFullPath(target.transform);
string errorMessage = string.IsNullOrEmpty(attribute.Message)
? $"[Required] {path}.{type.Name}.{field.Name} is null."
: $"[Required] {path}.{type.Name}.{field.Name}: {attribute.Message}";
Debug.LogError(errorMessage, target);
}
}
}
}
/// <summary>
/// Gets the full hierarchy path of the transform.
/// </summary>
private static string GetFullPath(Transform transform)
{
string path = transform.name;
while (transform.parent != null)
{
transform = transform.parent;
path = transform.name + "/" + path;
}
return "/" + path;
}
}
#if UNITY_EDITOR
/// <summary>
/// Custom property drawer for <see cref="RequiredAttribute"/>.
/// Handles the drawing of the error message box and red tinting for missing fields.
/// </summary>
[CustomPropertyDrawer(typeof(RequiredAttribute))]
public class RequiredAttributeDrawer : PropertyDrawer
{
/// <summary>
/// Gets the height of the help box.
/// </summary>
private float HelpBoxHeight => EditorGUIUtility.singleLineHeight * 2;
/// <inheritdoc />
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
bool isValueMissing = IsValueMissing(property);
bool isPropertyTypeInvalid = property.propertyType != SerializedPropertyType.ObjectReference;
// If there's an error state (missing or invalid type), draw the HelpBox
if (isValueMissing || isPropertyTypeInvalid)
{
float helpBoxHeight = HelpBoxHeight;
Rect helpBoxRectangle = new(position.x, position.y, position.width, helpBoxHeight);
string helpBoxMessage;
MessageType helpBoxMessageType;
if (isPropertyTypeInvalid)
{
helpBoxMessage = $"{typeof(RequiredAttribute).Name} only works on reference types";
helpBoxMessageType = MessageType.Warning;
}
else
{
// Get custom message if provided
RequiredAttribute requiredAttribute = (RequiredAttribute)attribute;
helpBoxMessage = string.IsNullOrEmpty(requiredAttribute.Message) ? $"{property.displayName} is required" : requiredAttribute.Message;
helpBoxMessageType = MessageType.Error;
}
EditorGUI.HelpBox(helpBoxRectangle, helpBoxMessage, helpBoxMessageType);
// Adjust position for the actual property field
position.y += helpBoxHeight + EditorGUIUtility.standardVerticalSpacing;
position.height -= helpBoxHeight + EditorGUIUtility.standardVerticalSpacing;
}
// Visual Polish: Tint the field red if it's missing to draw attention
Color originalBackgroundColor = GUI.backgroundColor;
if (isValueMissing)
{
GUI.backgroundColor = new(1f, 0.6f, 0.6f); // Soft red tint
}
EditorGUI.PropertyField(position, property, label, true);
// Restore original color
GUI.backgroundColor = originalBackgroundColor;
}
/// <inheritdoc />
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
float propertyHeight = EditorGUI.GetPropertyHeight(property, label);
if (IsValueMissing(property) || property.propertyType != SerializedPropertyType.ObjectReference)
{
propertyHeight += HelpBoxHeight + EditorGUIUtility.standardVerticalSpacing;
}
return propertyHeight;
}
/// <summary>
/// Checks if the property is a reference type and is null.
/// </summary>
private bool IsValueMissing(SerializedProperty property)
{
return property.propertyType == SerializedPropertyType.ObjectReference &&
property.objectReferenceValue == null;
}
}
/// <summary>
/// Automatically validates all objects in the scene when entering Play Mode and when new objects are instantiated.
/// </summary>
[InitializeOnLoad]
public static class RequiredAttributeGlobalValidator
{
private const string AutoValidationKey = "RequiredAttribute_AutoValidation";
private static readonly HashSet<int> ValidatedObjects = new();
/// <summary>
/// Gets or sets a value indicating whether automatic validation is enabled.
/// </summary>
public static bool IsAutoValidationEnabled
{
get => EditorPrefs.GetBool(AutoValidationKey, true);
set => EditorPrefs.SetBool(AutoValidationKey, value);
}
static RequiredAttributeGlobalValidator()
{
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
EditorApplication.hierarchyChanged += OnHierarchyChanged;
// Delay checking the menu to ensure the menu system is ready
EditorApplication.delayCall += () => Menu.SetChecked("Tools/Required Attribute/Enable Automatic Validation", IsAutoValidationEnabled);
}
private static void OnPlayModeStateChanged(PlayModeStateChange state)
{
if (state == PlayModeStateChange.ExitingEditMode || state == PlayModeStateChange.EnteredEditMode)
{
ValidatedObjects.Clear();
}
// Validate when entering Play Mode to ensure logs persist after "Clear on Play"
// Only run if the setting is enabled
if (state == PlayModeStateChange.EnteredPlayMode && IsAutoValidationEnabled)
{
ValidateNewObjects();
}
}
private static void OnHierarchyChanged()
{
// Only validate if playing and enabled
if (Application.isPlaying && IsAutoValidationEnabled)
{
ValidateNewObjects();
}
}
private static void ValidateNewObjects()
{
MonoBehaviour[] monoBehaviours = Object.FindObjectsByType<MonoBehaviour>(FindObjectsSortMode.None);
foreach (MonoBehaviour monoBehaviour in monoBehaviours)
{
// Only validate if we haven't already
if (monoBehaviour != null && ValidatedObjects.Add(monoBehaviour.GetInstanceID()))
{
monoBehaviour.Validate();
}
}
}
[MenuItem("Tools/Required Attribute/Enable Automatic Validation")]
private static void ToggleAutoValidation()
{
IsAutoValidationEnabled = !IsAutoValidationEnabled;
Menu.SetChecked("Tools/Required Attribute/Enable Automatic Validation", IsAutoValidationEnabled);
}
[MenuItem("Tools/Required Attribute/Validate Now")]
private static void ForceValidateAll()
{
MonoBehaviour[] monoBehaviours = Object.FindObjectsByType<MonoBehaviour>(FindObjectsSortMode.None);
foreach (MonoBehaviour monoBehaviour in monoBehaviours)
{
if (monoBehaviour != null)
{
monoBehaviour.Validate();
ValidatedObjects.Add(monoBehaviour.GetInstanceID());
}
}
Debug.Log($"[RequiredAttribute] Manual validation complete. Checked {monoBehaviours.Length} objects.");
}
}
#endif
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment